From 48e8fc47ae190969e603fd701c36a8045e6f5578 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 28 May 2026 22:39:12 +0800 Subject: [PATCH 01/26] 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/26] 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/26] =?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/26] =?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/26] =?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/26] =?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/26] =?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/26] 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/26] 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/26] 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/26] =?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/26] =?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/26] =?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/26] =?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/26] =?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/26] =?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/26] 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/26] 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/26] 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()) From 1e4e958b9256bbf9800c1aefc4833a406aafc009 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Wed, 14 Oct 2026 15:05:38 +0900 Subject: [PATCH 20/26] test(s3): add the SigV4 migration-compatibility notebook (S-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SigV4 回帰の NB は「新しい実装が、自分で作った設定に、自分で置いたデータ」で 動くことしか見ていない。既にある設定とデータを新しい実装がそのまま扱えるかは 別の話で、そこを見るための NB を足す。回帰の NB は変更していない。 1 回の通し実行では成立しないので、`phase` パラメータで 3 回に分ける。 a 試験環境を develop 版にして、旧実装で機関ストレージ登録・アドオン接続・ データ投入を行い、メタデータとダウンロードの SHA-256 を控える b 試験環境を対象 PR 版にして、**設定を一切触らずに**同じものを開き、 a の控えと突き合わせる。c で読む新データもここで置く c 試験環境を develop 版に戻して、b が置いた新データを読み、後始末まで行う 段をまたぐ控えは S-10-state.json。資格情報は書かない。a / b では delete_generated_buckets を強制的に False にして、バケットもプロジェクトも c の後始末まで残す。b で register_institutional_s3 / connect_s3_addon を 呼ばないのは、呼ぶと確かめたかった設定が作り直されてしまうため(例外は 新規プロジェクトへの新規接続を見る S-10b-11 のみ)。 置くデータは名前そのものが検証対象になるものを選んだ: 日本語、空白、`+`、 括弧、GRDM の画面からは作れない `S-10-raw/a+b %25 c~d.txt`(boto3 で直接置く)、 2 階層のフォルダ、200MB(マルチパートの ETag `-N`)、同一キーの 3 版。 一覧と単体取得は WaterButler の中で別のクラスがメタデータを作るので、 両方を採って突き合わせる。引用符付きの ETag はそれ自体を差分として出す (引用符を落としてから比べると隠れてしまうため)。 関数セルは回帰 NB から複製し、複製元のセル番号をコメントに残した。 フォルダ作成だけは取りまとめ NB に手順が無かったので、ファイル基本操作 NB の セル [43] / [45] / [47] と同じ組み立てで新しく書いた。 --- README.md | 18 + ...350\241\214\344\272\222\346\217\233.ipynb" | 3365 +++++++++++++++++ 2 files changed, 3383 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-SigV4\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" diff --git a/README.md b/README.md index 5f64991..8025c80 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,24 @@ GRDMには、一般的なGUI操作を行うためのユーティリティ関数 `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 の後始末より前に実行すること)。 +### 移行互換 Notebook (S-10) + +`テスト手順-管理者機能-S3-機関ストレージ-SigV4移行互換.ipynb` は、**develop 版で作られた設定とデータを対象 PR 版がそのまま扱えるか** (上書きデプロイの可否) を確かめる Notebook です。回帰試験の Notebook とは別ファイルで、回帰試験側は変更していません。 + +**1 回の通し実行では成立しません。** 途中で試験環境のコードを入れ替える必要があるため、`phase` パラメータを変えて 3 回実行します。 + +| 回 | `phase` | 実行前に試験環境を | 内容 | +|---|---|---|---| +| 1 回目 | `'a'` | **develop 版**に切り替える | 旧実装で機関ストレージ登録・S3 アドオン接続・データ投入を行い、メタデータとダウンロードの SHA-256 を控える | +| 2 回目 | `'b'` | **対象 PR 版**に切り替える | **設定を一切変更せずに**同じプロジェクトを開き、1 回目の控えと突合する。3 回目で読む新データもここで置く | +| 3 回目 | `'c'` | **develop 版**に戻す | 2 回目が置いた新データを読み、後始末まで行う | + +段をまたぐ控えは `S-10-state.json` (既定で 1 回目の証跡ディレクトリの下) です。2 回目以降は `state_path` パラメータでそのパスを必ず渡してください。**資格情報は控えに書きません。** + +**3 回目の後始末までバケットとプロジェクトを消さないでください。** `delete_generated_buckets` は 1 回目と 2 回目では強制的に `False` になり (パラメータで `True` を渡しても無効化されます)、生成したバケットを削除するのは 3 回目の末尾だけです。プロジェクトの削除と S3 アカウントの連携解除、機関ストレージ設定の復元は 3 回目の後始末チェックリストに従ってください。 + +2 回目では `register_institutional_s3()` と `connect_s3_addon()` を呼びません。呼ぶと確かめたかった設定が作り直されてしまうためです (例外は新規プロジェクトへの新規接続を見る S-10b-11 のみ)。 + ## セキュリティと機密情報の管理 このリポジトリをGitで管理・公開する際は、機密情報の流出を防ぐためにpre-commit hookを設定してください。 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" new file mode 100644 index 0000000..bab49b4 --- /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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" @@ -0,0 +1,3365 @@ +{ + "cells": [ + { + "cell_type": "code", + "metadata": { + "tags": [ + "parameters" + ] + }, + "source": [ + "from datetime import datetime\n", + "from getpass import getpass\n", + "\n", + "# 本ノートブックは テスト手順-管理者機能-S3-機関ストレージ-SigV4回帰.ipynb の型を\n", + "# 複製し、既存データの移行互換(S-10)を見るものに作り替えたもの。\n", + "# 回帰ノートブックは変更していない。複製したセルには複製元のセル番号を\n", + "# コメントで残してある(例: 「回帰 NB セル [6] の複製」)。\n", + "\n", + "# === この NB 固有のパラメータ ==============================================\n", + "# phase: 'a' 仕込み(develop 版) / 'b' 検証(対象 PR 版) / 'c' ロールバック(develop 版)。\n", + "# None のままなら最初のセルで input() で聞く。papermill では必ず与えること。\n", + "phase = None\n", + "# 段をまたぐ控え。None なら evidence_dir/S-10-state.json。\n", + "# phase a が書き、b / c が読む。**資格情報は書かない。**\n", + "state_path = None\n", + "# ドット入りバケット(名前にドットを 2 つ入れる)を作るか。任意シナリオ S-10b-10。\n", + "run_dotted = True\n", + "# 200MB のマルチパートを含めるか。\n", + "run_chunked = True\n", + "# ===========================================================================\n", + "\n", + "# URL は .config.yaml(admin_rdm_url / rdm_url)か papermill で与える。次のセルの\n", + "# 補完は None のパラメータにしか効かないので、ここに既定値を書くと .config.yaml が\n", + "# 無視される。\n", + "admin_rdm_url = None\n", + "rdm_url = None\n", + "idp_name_1 = 'GakuNin RDM IdP'\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", + "\n", + "# 機関ストレージ設定用パラメータ\n", + "target_organization = None\n", + "\n", + "# Amazon S3 設定。資格情報は .config.yaml か実行時プロンプトで与える。\n", + "# **ここに値を書かない**。\n", + "s3_access_key = None\n", + "s3_secret_key = None\n", + "\n", + "# バケット。phase a で生成し、**phase c の後始末まで消さない**。\n", + "# phase b / c は S-10-state.json から名前を読むので、ここは None のままでよい。\n", + "s3_bucket_mig = None # 通常バケット。*-inst / *-addon / *-newconn で使う\n", + "s3_bucket_migver = None # バージョニング有効。*-ver で使う\n", + "s3_bucket_dot = None # ドット入り名。任意の *-dot で使う\n", + "\n", + "bucket_prefix = 'e2e-sigv4'\n", + "bucket_suffix = None\n", + "# **phase a / b では強制的に False にする**(次のセル)。phase c の末尾でだけ\n", + "# cleanup_buckets() を呼んで消す。\n", + "delete_generated_buckets = False\n", + "\n", + "s3_region_default = 'us-east-1'\n", + "s3_server_side_encryption = False\n", + "\n", + "# 機関ストレージの表示名(ファイルツリーに表示される名前)\n", + "institutional_storage_name = 'NII Storage'\n", + "\n", + "# S3 アドオン(外部ストレージ)の表示名と ID\n", + "addon_storage_name = 'Amazon S3'\n", + "addon_storage_id = 's3'\n", + "\n", + "# S-10a で置くファイル。名前そのものが検証対象(SigV2 → SigV4 でエンコード規則が\n", + "# 変わる文字を含める)。\n", + "f_ascii = 'S-10-ascii.txt'\n", + "f_japanese = 'S-10-日本語.txt'\n", + "f_symbols = 'S-10-日本語 名前+記号(1).txt'\n", + "f_space = 'S-10 space.txt'\n", + "f_nested = 'nested.txt'\n", + "f_raw_key = 'S-10-raw/a+b %25 c~d.txt' # boto3 で直接置く(GRDM 外から置かれたキー)\n", + "f_ver = 'S-10-ver.txt'\n", + "f_chunked = 'S-10-200MB.bin'\n", + "folder_top = 'S-10-folder'\n", + "folder_sub = 'サブ'\n", + "\n", + "# S-10b-8 で置く新データ(S-10c で読む)\n", + "f_new_japanese = 'S-10b-new-日本語.txt'\n", + "f_new_chunked = 'S-10b-new-200MB.bin'\n", + "f_new_ver = 'S-10b-ver.txt'\n", + "# S-10b-11 で置くファイル\n", + "f_newconn = 'S-10b-newconn.txt'\n", + "\n", + "# 200MB。WB の CONTIGUOUS_UPLOAD_SIZE_LIMIT(既定 128,000,000)を超えないと\n", + "# マルチパートにならない。\n", + "chunked_file_size = 200000000\n", + "wb_chunk_size = 64000000\n", + "wb_contiguous_upload_size_limit = 128000000\n", + "\n", + "# 証跡の保存先。既定は ./evidence/run-(リポジトリ直下)\n", + "evidence_dir = None\n", + "\n", + "# プロジェクト名プレフィックス。**phase a が決めて控えに書き、b / c は控えから読む**\n", + "# (phase ごとに作り直すと別のプロジェクトを見てしまう)。回帰 NB とは別の名前に\n", + "# するため末尾に -mig を付ける。\n", + "rdm_project_prefix = None" + ], + "id": "cell-000", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [1] の複製。\n", + "# .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 なし(プロンプト入力で続行)')" + ], + "id": "cell-001", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# phase を確定し、段ごとの不変条件を立てる。\n", + "if phase is None:\n", + " phase = input(prompt='phase (a=仕込み / b=検証 / c=ロールバック)')\n", + "phase = str(phase).strip().lower()\n", + "assert phase in ('a', 'b', 'c'), f'phase は a / b / c のいずれか: {phase!r}'\n", + "\n", + "# phase a / b でバケットを消すと次の段で見るものが無くなる。パラメータで True が\n", + "# 来ても握りつぶさず、ここで落とす。\n", + "if phase in ('a', 'b') and delete_generated_buckets:\n", + " print('[!] phase a / b では delete_generated_buckets を False に強制する'\n", + " '(次の段でバケットを読むため)')\n", + " delete_generated_buckets = False\n", + "\n", + "_PHASE_NOTE = {\n", + " 'a': '仕込み。**試験環境は develop 版**であること',\n", + " 'b': '検証。**試験環境は対象 PR 版**であること。設定は一切変更しない',\n", + " 'c': 'ロールバック。**試験環境は develop 版**に戻してあること',\n", + "}\n", + "print(f'phase = {phase} — {_PHASE_NOTE[phase]}')\n", + "print(f'delete_generated_buckets = {delete_generated_buckets}')" + ], + "id": "cell-002", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [2] / [3] の複製(バケット名を聞かない点も同じ)。\n", + "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", + "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", + "(len(idp_username_1), len(idp_password_1), len(admin_username), len(admin_password),\n", + " len(s3_access_key), len(s3_secret_key))" + ], + "id": "cell-003", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [4] の複製(shutil を明示的に import する点だけ足した)。\n", + "import hashlib\n", + "import json\n", + "import os\n", + "import shutil\n", + "import tempfile\n", + "\n", + "work_dir = tempfile.mkdtemp()\n", + "\n", + "if evidence_dir is None:\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", + "\n", + "# Playwright の成果物(video-N.webm / har.zip / console.log / last-*.png|html)は\n", + "# scripts/playwright.py が default_result_path 配下へ書く。既定のままだと\n", + "# work_dir に落ち、最終セルの `!rm -fr {work_dir}` ごと消える。har.zip は 302 の\n", + "# 判定に使うので、既定を run_dir にする。\n", + "if default_result_path is None:\n", + " default_result_path = run_dir\n", + "\n", + "\n", + "def evidence(name):\n", + " \"\"\"証跡ファイルのパスを作る。名前には S-番号を含めること。\"\"\"\n", + " return os.path.join(run_dir, name)\n", + "\n", + "\n", + "def record(name, text, quiet=False):\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", + "def record_json(name, obj, quiet=True):\n", + " return record(name, json.dumps(obj, indent=2, ensure_ascii=False), quiet=quiet)\n", + "\n", + "\n", + "def sha256_bytes(data):\n", + " return hashlib.sha256(data).hexdigest()\n", + "\n", + "\n", + "def sha256_file(path):\n", + " h = hashlib.sha256()\n", + " with open(path, 'rb') as f:\n", + " for chunk in iter(lambda: f.read(1024 * 1024), b''):\n", + " h.update(chunk)\n", + " return h.hexdigest()\n", + "\n", + "\n", + "print(run_dir)\n", + "print(work_dir)" + ], + "id": "cell-004", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# GakuNinRDM 総合テスト [Amazon S3 — SigV4 移行互換 (S-10)]\n", + "\n", + "- サブシステム名: 管理者 / ストレージ / WaterButler\n", + "- ページ/アドオン: 機関ストレージ (Amazon S3) および S3 アドオン\n", + "- 機能分類: 既存データの移行互換(上書きデプロイの可否判定)\n", + "- シナリオ名: **develop 版で作った設定とデータを、対象 PR 版がそのまま扱えるか**を\n", + " 確かめる。ついでに対象 PR 版で置いたデータを develop 版に戻して読めるかも見る\n", + "- **対象 PR: RDM-waterbutler#93(`feature/s3-sigv4`)/ RDM-osf.io#746**\n", + "- 取る証跡: `S-10a-*`(仕込み)/ `S-10b-*`(検証)/ `S-10c-*`(ロールバック)\n", + "- 用意するテストデータ: URL 一覧、アカウント(既存ユーザー1: GRDM)、管理者アカウント、\n", + " AWS S3 のアクセスキー/シークレットキー。バケットは phase a が生成する\n", + "\n", + "## このノートブックは 3 回に分けて実行する\n", + "\n", + "**1 回の通し実行では成立しない。** 途中で試験環境のコードを入れ替える必要があるため、\n", + "`phase` パラメータを変えて 3 回実行する。\n", + "\n", + "| 回 | `phase` | 実行前に試験環境を | 何をするか |\n", + "|---|---|---|---|\n", + "| 1 回目 | `'a'` | **develop 版**に切り替える | 旧実装で機関ストレージ登録・アドオン接続・データ投入を行い、メタデータとハッシュを控える |\n", + "| 2 回目 | `'b'` | **対象 PR 版**に切り替える | **設定を一切触らずに**同じものを開き、1 回目の控えと突合する。新データも置く |\n", + "| 3 回目 | `'c'` | **develop 版**に戻す | 2 回目が置いた新データを読み、後始末まで行う |\n", + "\n", + "段をまたぐ控えは `S-10-state.json`(既定で証跡ディレクトリの下)。\n", + "**資格情報は控えに書かない。**\n", + "\n", + "### phase 間で消してはいけないもの\n", + "\n", + "- phase a が生成したバケット(`*-mig` / `*-migver` / 任意の dot 入り)\n", + "- phase a が作ったプロジェクト(`*-inst` / `*-addon` / `*-ver` / 任意の `*-dot`)\n", + "- `S-10-state.json`\n", + "\n", + "`delete_generated_buckets` は phase a / b では**強制的に False** になる\n", + "(パラメータで True を渡しても落とす)。消すのは phase c の末尾だけ。\n", + "\n", + "## phase b で設定を作り直してはいけない理由\n", + "\n", + "phase b は「**develop 版が作った** `ExternalAccount` / `NodeSettings` / 機関ストレージ\n", + "設定を、対象 PR 版がそのまま読めるか」を見るためにある。\n", + "`register_institutional_s3()` や `connect_s3_addon()` を phase b で呼ぶと、\n", + "その設定が**対象 PR 版で作り直されて**しまい、確かめたかったことが消える。\n", + "phase b でこの 2 つを呼ぶのは S-10b-11(新規プロジェクトへの新規接続)だけで、\n", + "これは「新実装で新しく接続したものがどう保存されるか」を見る別の目的のもの。\n", + "\n", + "## S-10 の判定項目\n", + "\n", + "| 番号 | 何を見るか | 段 |\n", + "|---|---|---|\n", + "| S-10b-1 | 機関ストレージ設定が表示だけで読め、再保存を求められない | b |\n", + "| S-10b-2 | `*-inst` の一覧・ダウンロード(SHA-256 一致、302 → presigned) | b |\n", + "| S-10b-3 | `*-addon` の一覧(フォルダ展開、`+` ` ` `%25` `~` を含むキー) | b |\n", + "| S-10b-4 | `?meta=`(単体取得)と一覧の `etag` / `extra.md5` / `size` が phase a と一致。**ETag に引用符が残っていないこと** | b |\n", + "| S-10b-5 | 全ファイルのダウンロード SHA-256 一致 | b |\n", + "| S-10b-6 | intra move → copy | b |\n", + "| S-10b-7 | バージョン付きファイルの UI 削除で Versions / DeleteMarkers とも 0 | b |\n", + "| S-10b-8 | 新データ投入(S-10c 用) | b |\n", + "| S-10b-9 | 全段を通して WB 応答に 4xx/5xx と署名系の文言が無い | b |\n", + "| S-10b-10(任意) | ドット入りバケット名の一覧・ダウンロード | b |\n", + "| S-10b-11 | 新規プロジェクトに**新規接続**し、`folder_id` の保存形を記録 | b |\n", + "| S-10c-1 | 新データを develop 版で一覧・ダウンロード・版削除 | c |\n", + "| S-10c-2 | S-10b-11 で接続したプロジェクトを develop 版で開き、**何が起きるかを記録**(失敗が期待値。例外で止めない) | c |\n", + "\n", + "## 機関ストレージの登録は「追加」ではなく「上書き」である\n", + "\n", + "機関ストレージは機関あたり 1 つしかなく、phase a の登録は**現在の設定を破棄する**。\n", + "`S-10a-institutional-storage-before.txt` に登録前の状態が残るので、\n", + "phase c の後始末チェックリストで必ず元に戻すこと。**「登録を外す」操作は無い。**" + ], + "id": "cell-005" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [6] の複製。役割を std/ver/apne1 から mig/migver/dot に替え、\n", + "# **phase をまたいで生かす**ために次の 2 点を変えてある。\n", + "# - 名前はパラメータか S-10-state.json から来る(phase b / c は生成しない)\n", + "# - delete_generated_buckets は phase c でしか True にならない(前のセル)\n", + "#\n", + "# 後始末は atexit に登録するので、以降のセルが例外で止まってもカーネル終了時に\n", + "# 走る。**ただし phase a / b では delete_generated_buckets=False なので消えない。**\n", + "import atexit\n", + "import random\n", + "import re\n", + "import time\n", + "\n", + "from botocore.exceptions import ClientError\n", + "\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", + "generated_buckets = []\n", + "generated_bucket_names = []\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 _check_credentials():\n", + " \"\"\"CreateBucket の前に、資格情報が AWS に通ることだけを確かめる。\n", + "\n", + " sts:GetCallerIdentity は IAM 権限を必要としないので、「鍵が違う」のか\n", + " 「権限が足りない」のかを切り分けられる。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", + " 'ものか確認すること。') 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", + "\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}'\n", + " return name\n", + "\n", + "\n", + "def _dotted_name(suffix):\n", + " \"\"\"ドット入りの名前。仮想ホスト形式が TLS で通らない名前の代表。\n", + "\n", + " ドットを含む名前は上の正規表現を通らないので別に作る。S3 の規則では\n", + " ドットは使えるが、`.s3..amazonaws.com` の証明書が\n", + " ワイルドカード 1 段ぶんしか無いため、SDK はパス形式に落ちる。\n", + " \"\"\"\n", + " name = f'{bucket_prefix}.{suffix}.mig'.lower()\n", + " assert re.fullmatch(r'[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]', name), \\\n", + " f'S3 のバケット名として使えない: {name}'\n", + " return name\n", + "\n", + "\n", + "def _create_bucket(name, region):\n", + " \"\"\"バケットを作る。戻り値 'created' / 'exists-own'。\"\"\"\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", + " c.put_bucket_versioning(\n", + " Bucket=name, VersioningConfiguration={'Status': 'Enabled'})\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 にならない')\n", + "\n", + "\n", + "def _purge_bucket(name, region):\n", + " \"\"\"中身(全バージョン・削除マーカー・進行中の multipart)を空にする。\"\"\"\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", + " \"\"\"このノートブックが作ったバケットを中身ごと削除する。\n", + "\n", + " **phase a / b では delete_generated_buckets=False なので名前を出すだけ。**\n", + " 実際に消えるのは phase c の末尾で呼んだときだけ。\n", + " \"\"\"\n", + " lines = []\n", + " if not generated_buckets:\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", + " 'ため残した(次の段で使う)')\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", + " 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", + "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", + " \"\"\"バケットの前提(所在・中身の有無・バージョニング)を採る。\"\"\"\n", + " c = s3_client(region)\n", + " listed = c.list_objects_v2(Bucket=bucket, MaxKeys=5)\n", + " # GetBucketLocation は us-east-1 を None(旧 API では '')で返す。\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", + "print('バケット操作のヘルパを定義した(生成はこの後のセル)')" + ], + "id": "cell-006", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# S-10-state.json: 段をまたぐ控え。**資格情報は書かない。**\n", + "#\n", + "# phase a が書き、b / c が読む。証跡ディレクトリは実行ごとに変わる(run_id が\n", + "# 時刻)ので、控えは既定で**最初の phase a の証跡ディレクトリ**に置かれる。\n", + "# phase b / c では state_path をそのパスで与えること(与えないと今回の run_dir を\n", + "# 見にいって「無い」で止まる)。\n", + "if state_path is None:\n", + " state_path = evidence('S-10-state.json')\n", + "state_path = os.path.abspath(state_path)\n", + "\n", + "\n", + "def save_state(state):\n", + " state['updated_at'] = datetime.now().isoformat()\n", + " text = json.dumps(state, indent=2, ensure_ascii=False)\n", + " # 短い文字列は控えの中の無関係な箇所に偶然現れる(1 文字のダミーパスワードで\n", + " # 必ず落ちる)。8 文字未満は突き合わせから外し、外したことを黙らずに出す。\n", + " for secret in (s3_access_key, s3_secret_key, idp_password_1, admin_password):\n", + " if not secret:\n", + " continue\n", + " if len(secret) < 8:\n", + " print('[!] 8 文字未満の資格情報があるため、控えの混入チェックから'\n", + " '外した(偶然の一致で必ず落ちるため)')\n", + " continue\n", + " assert secret not in text, '控えに資格情報が混ざっている'\n", + " os.makedirs(os.path.dirname(state_path), exist_ok=True)\n", + " with open(state_path, 'w') as f:\n", + " f.write(text)\n", + " print(f'--- 控えを書いた: {state_path} ({len(text)} bytes)')\n", + " return state\n", + "\n", + "\n", + "def load_state():\n", + " if not os.path.exists(state_path):\n", + " raise AssertionError(\n", + " f'控えが無い: {state_path}\\n'\n", + " 'phase a を先に実行し、その証跡ディレクトリの S-10-state.json を'\n", + " ' state_path パラメータで指すこと')\n", + " with open(state_path) as f:\n", + " return json.load(f)\n", + "\n", + "\n", + "if phase == 'a':\n", + " state = {\n", + " 'created_at': datetime.now().isoformat(),\n", + " 'phase_runs': {},\n", + " 'buckets': {},\n", + " 'projects': {},\n", + " 'files': {}, # 論理名 -> {'sha256':…, 'size':…, 'where':…}\n", + " 'expected': {}, # 200MB のパート数など\n", + " }\n", + " print('phase a: 控えを新規に作る')\n", + "else:\n", + " state = load_state()\n", + " print(f'phase {phase}: 控えを読んだ(作成 {state.get(\"created_at\")})')\n", + " print(json.dumps({k: state.get(k) for k in ('buckets', 'projects')},\n", + " indent=2, ensure_ascii=False))\n", + "\n", + "state.setdefault('phase_runs', {})[phase] = {'run_id': run_id, 'run_dir': run_dir}" + ], + "id": "cell-007", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# バケットの確定。\n", + "# phase a: 生成して控えに書く\n", + "# phase b / c: 控えから読むだけ(**作らない**)\n", + "_check_credentials()\n", + "\n", + "_region_of = {'mig': s3_region_default, 'migver': s3_region_default,\n", + " 'dot': s3_region_default}\n", + "_used_by = {'mig': ['*-inst', '*-addon', '*-newconn'], 'migver': ['*-ver'],\n", + " 'dot': ['*-dot']}\n", + "\n", + "if phase == 'a':\n", + " if bucket_suffix is None:\n", + " bucket_suffix = _new_bucket_suffix()\n", + " _roles = ['mig', 'migver'] + (['dot'] if run_dotted else [])\n", + " _given = {'mig': s3_bucket_mig, 'migver': s3_bucket_migver,\n", + " 'dot': s3_bucket_dot}\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 = (_dotted_name(bucket_suffix) if _role == 'dot'\n", + " else _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", + " except ClientError as _e:\n", + " if _e.response['Error']['Code'] != 'BucketAlreadyExists' or _attempt == 2:\n", + " raise\n", + " print('BucketAlreadyExists(他人所有の名前と衝突)。'\n", + " 'suffix を変えて 1 回だけ再試行する')\n", + " # 作りかけは消す。delete_generated_buckets=False なので cleanup_buckets\n", + " # では消えない。ここだけは直接消す。\n", + " for _n, _r in list(generated_buckets):\n", + " try:\n", + " s3_client(_r).delete_bucket(Bucket=_n)\n", + " except ClientError:\n", + " pass\n", + " generated_buckets.remove((_n, _r))\n", + " generated_bucket_names.clear()\n", + " bucket_suffix = _new_bucket_suffix()\n", + "\n", + " s3_bucket_mig = _resolved['mig'][0]\n", + " s3_bucket_migver = _resolved['migver'][0]\n", + " if _resolved['migver'][2]:\n", + " _enable_versioning(s3_bucket_migver, _resolved['migver'][1])\n", + " s3_bucket_dot = _resolved['dot'][0] if run_dotted else None\n", + "\n", + " state['buckets'] = {\n", + " 'prefix': bucket_prefix, 'suffix': bucket_suffix,\n", + " 'mig': s3_bucket_mig, 'migver': s3_bucket_migver, 'dot': s3_bucket_dot,\n", + " 'region': s3_region_default,\n", + " 'generated': [list(x) for x in generated_bucket_names],\n", + " }\n", + "else:\n", + " _b = state['buckets']\n", + " s3_bucket_mig = _b['mig']\n", + " s3_bucket_migver = _b['migver']\n", + " s3_bucket_dot = _b.get('dot')\n", + " bucket_prefix, bucket_suffix = _b['prefix'], _b['suffix']\n", + " run_dotted = run_dotted and bool(s3_bucket_dot)\n", + " # phase c の後始末で消すのは「phase a が生成した」ものだけ。控えから戻す。\n", + " generated_buckets[:] = [tuple(x) for x in _b.get('generated', [])]\n", + " generated_bucket_names[:] = list(generated_buckets)\n", + " print(f' 控えから: mig={s3_bucket_mig} / migver={s3_bucket_migver} / '\n", + " f'dot={s3_bucket_dot}')\n", + "\n", + "_roles_now = ['mig', 'migver'] + (['dot'] if run_dotted else [])\n", + "_names_now = {'mig': s3_bucket_mig, 'migver': s3_bucket_migver,\n", + " 'dot': s3_bucket_dot}\n", + "_states = {}\n", + "for _r in _roles_now:\n", + " _s = bucket_state(_names_now[_r], _region_of[_r],\n", + " any(_n == _names_now[_r] for _n, _rg in generated_buckets))\n", + " _s['role'] = _r\n", + " _s['used_by'] = _used_by[_r]\n", + " assert _s['GetBucketLocation'] == _s['declared_region'], (\n", + " f\"{_s['bucket']} の所在は {_s['GetBucketLocation']} で、\"\n", + " f\"宣言した {_s['declared_region']} と違う\")\n", + " _states[_r] = _s\n", + "assert _states['migver']['Versioning'] == 'Enabled', \\\n", + " f'{s3_bucket_migver} のバージョニングが有効でない(S-10b-7 の前提)'\n", + "\n", + "record_json(f'S-10{phase}-buckets.json', {\n", + " 'phase': phase,\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_now],\n", + " 'note': ('phase a が生成し、phase c の後始末まで残す。'\n", + " 'GetBucketLocation_raw が null なのは us-east-1 のバケット。'),\n", + "}, quiet=False)" + ], + "id": "cell-008", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# プロジェクト名。phase a が決めて控えに書き、b / c は控えから読む。\n", + "if phase == 'a':\n", + " if rdm_project_prefix is None:\n", + " rdm_project_prefix = 'TEST-S3-SIGV4-{}-mig'.format(\n", + " datetime.now().strftime('%Y%m%d-%H%M%S'))\n", + " state['projects'] = {\n", + " 'prefix': rdm_project_prefix,\n", + " 'inst': f'{rdm_project_prefix}-inst',\n", + " 'addon': f'{rdm_project_prefix}-addon',\n", + " 'ver': f'{rdm_project_prefix}-ver',\n", + " 'dot': f'{rdm_project_prefix}-dot' if run_dotted else None,\n", + " # S-10b-11 で phase b が作る。名前だけ先に決めておく(phase c が読む)。\n", + " 'newconn': f'{rdm_project_prefix}-newconn',\n", + " }\n", + "else:\n", + " rdm_project_prefix = state['projects']['prefix']\n", + "\n", + "p_inst = state['projects']['inst']\n", + "p_addon = state['projects']['addon']\n", + "p_ver = state['projects']['ver']\n", + "p_dot = state['projects'].get('dot')\n", + "p_newconn = state['projects']['newconn']\n", + "save_state(state)\n", + "print('\\n'.join(f' {k}: {v}' for k, v in state['projects'].items()))" + ], + "id": "cell-009", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [7] の複製。\n", + "import asyncio\n", + "import importlib\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)" + ], + "id": "cell-010", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [9] の複製。WB が返した HTTP ステータスと本文を捕まえる。\n", + "wb_responses = []\n", + "\n", + "# 一覧応答(?meta=)は本文を解析するので切らない。それ以外は証跡が膨らむので切る。\n", + "WB_BODY_LIMIT = 2000\n", + "WB_BODY_LIMIT_META = 4 * 1024 * 1024\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", + " return\n", + " entry = {\n", + " 'status': response.status,\n", + " 'method': response.request.method,\n", + " 'url': url,\n", + " 'at': datetime.now().isoformat(),\n", + " }\n", + " if response.request.method != 'GET':\n", + " # action: \"move\" / \"copy\" は *要求* 本文にしかない。\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", + " 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", + " _recorder_attached.append(page)\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", + " # 一覧ツールバーのダウンロード URL には OSF の accessToken が `?token=` で乗る。\n", + " out = re.sub(r'([?&]token=)[^&\\s\"\\\\]+', r'\\1***', 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', 'InvalidRequest')\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", + "# 全段を通した観測(S-10b-9 / S-10c)。dump_wb_responses で消える前に溜める。\n", + "wb_all = []\n", + "\n", + "\n", + "def keep_all(name):\n", + " \"\"\"証跡に落としつつ、全段ぶんの控えにも足す。\"\"\"\n", + " wb_all.extend(wb_responses)\n", + " return dump_wb_responses(name)" + ], + "id": "cell-011", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [9] 後半の複製(ストレージノードを開く・growl・アップロード待ち)。\n", + "async def open_storage(page, storage_name):\n", + " \"\"\"ファイルツリーのストレージノードを開き、アップロードボタンが出る状態にする。\"\"\"\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(\n", + " '//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 の値で決まるので環境ごとに違う。\n", + " ノードのアイコン(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", + " 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", + " 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", + "async def upload_and_wait(page, path):\n", + " \"\"\"アップロードして、行が出るまで待つ。\"\"\"\n", + " name = os.path.basename(path)\n", + " await grdm.upload_file(page, path)\n", + " await wait_for_upload_finished(page, name,\n", + " timeout=transition_timeout * 60)\n", + " await expect(grdm.get_select_file_title_locator(page, name)\n", + " ).to_be_visible(timeout=transition_timeout * 3)\n", + " return name" + ], + "id": "cell-012", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [15] の複製。\n", + "import urllib.parse\n", + "\n", + "\n", + "async def admin_login(page):\n", + " await page.goto(admin_rdm_url)\n", + " email_field = page.locator('#id_email')\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", + " elif admin_idp_entity_id is not None:\n", + " # entityID 直接指定で Shibboleth ログインを開始(Embedded DS バイパス)\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", + " title = await page.title()\n", + " assert 'Unknown Identity Provider' not in title, (\n", + " f'SPが entityID を認識していません: {admin_idp_entity_id}')\n", + " else:\n", + " # GakuNin Embedded DS(「所属している機関を選択」)から IdP を選ぶ。\n", + " # embedded-wayf.js の submitForm() は #keytext を IdP 名と突き合わせ、\n", + " # hidden の #user_idp に entityID を入れてから送信する。\n", + " assert admin_idp_name is not None, (\n", + " '管理者ログインがメール/パスワード形式ではない(Embedded DS が出ている)。'\n", + " '.config.yaml に admin_idp_name を書くか、admin_idp_entity_id を'\n", + " '直接指定すること。')\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", + " print(f'[1] Embedded DS: {admin_idp_name} -> entityID={entity_id}')\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 -> search の\n", + " # 順でしか disabled が外れず、checkLoop のタイマは**キーイベントでしか\n", + " # 起動しない**。fill() は input イベントしか出さないので 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", + " logout_link = page.locator('//*[@href=\"/account/logout/\"]')\n", + " await expect(username.or_(j_username).or_(consent).or_(logout_link).first\n", + " ).to_be_visible(timeout=transition_timeout)\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(\n", + " '//button[@type=\"submit\"] | //input[@type=\"submit\"]').first.click()\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(\n", + " '//button[@type=\"submit\"] | //input[@type=\"submit\"]').first.click()\n", + " else:\n", + " print('[2] ログインフォームなし(既ログイン or 同意画面):', page.url)\n", + "\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ログイン失敗: '\n", + " + (await err.first.text_content()).strip())\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_btn = page.locator(\n", + " '//*[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(\n", + " timeout=transition_timeout)\n", + " except AssertionError:\n", + " print('[4] 最終確認失敗。現在地:', page.url, '/', await page.title())\n", + " print(await page.evaluate(\n", + " '() => document.body.innerText.substring(0, 800)'))\n", + " raise\n", + " print('[4] 管理者画面ログイン成功:', page.url)\n", + "\n", + "\n", + "async def admin_logout(page):\n", + " \"\"\"回帰 NB セル [22] の複製。\"\"\"\n", + " logout_btn = page.locator(\n", + " '//*[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", + " await expect(page.locator('.login-logo')).to_be_visible(\n", + " timeout=transition_timeout)" + ], + "id": "cell-013", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [17] の複製。\n", + "async def goto_institutional_storage(page):\n", + " await page.goto(admin_rdm_url.rstrip('/')\n", + " + '/custom_storage_location/institutional_storage/')\n", + " # サイドメニューの「機関ストレージ」(「機関ストレージのクォータ」と区別)\n", + " link = page.locator(\n", + " '//a[contains(@href, \"institutional_storage\")'\n", + " ' and not(contains(@href, \"quota\"))]').first\n", + " await expect(link).to_be_visible(timeout=transition_timeout)\n", + " await link.click()\n", + " await expect(page.locator('//input[@type=\"radio\"]').first\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + "\n", + "async def select_institution(page):\n", + " \"\"\"機関のリストから target_organization を選ぶ。\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", + " '既に設定画面とみなす')\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", + " ' var b = document.getElementById(\"s3_bucket\");'\n", + " ' return {selected: r ? r.value : \"none\",'\n", + " ' storage_name: n ? n.value : null,'\n", + " ' s3_bucket_field: b ? b.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_" + ], + "id": "cell-014", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [18] の複製。**phase a でしか呼ばない。**\n", + "async def register_institutional_s3(page, bucket, label):\n", + " \"\"\"Amazon S3 を機関ストレージとして登録する。**既存設定の上書きである。**\"\"\"\n", + " await goto_institutional_storage(page)\n", + " await select_institution(page)\n", + "\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 = 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}, '\n", + " f'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_btn = page.locator(\n", + " '//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 が bind する。\n", + " # ラジオボタンは HTML の解析時点で見えるので、見えた直後に押すとハンドラが\n", + " # まだ無く、素のフォーム送信(GET)でページが再読込みされるだけになる。\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", + "\n", + " confirm_text = page.locator('#bbConfirmText')\n", + " s3_modal = page.locator('#s3_modal')\n", + " # 確認ダイアログ(bootbox)は osfstorage 以外では必ず出る。出ないのは送信が\n", + " # 成立していないということなので、黙って先へ進めない。\n", + " try:\n", + " await expect(confirm_text).to_be_visible(timeout=10000)\n", + " except Exception:\n", + " raise AssertionError(\n", + " '保存の確認ダイアログが出ない。フォーム送信が成立していない'\n", + " '(submit ハンドラ未 bind、または checkValidity 失敗)。')\n", + " confirm_strong = page.locator(\n", + " '//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 が残る。growl は自動では消えない。\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", + " await expect(s3_modal).to_be_visible(timeout=20000)\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", + " 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_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", + "\n", + " # モーダルを閉じただけでは、ページは保存前に描画されたままである。\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", + " print(f'{label}: {bucket} を機関ストレージとして登録した')" + ], + "id": "cell-015", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [24] 〜 [26] の複製。\n", + "import scripts.grdm\n", + "importlib.reload(scripts.grdm)\n", + "\n", + "\n", + "async def user_login(page):\n", + " await page.goto(rdm_url)\n", + " await page.wait_for_load_state('networkidle')\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", + " await scripts.grdm.expect_anonymous_toppage(\n", + " page, idp_name_1, transition_timeout=transition_timeout)\n", + " await scripts.grdm.login(\n", + " page, idp_name_1, idp_username_1, idp_password_1,\n", + " transition_timeout=transition_timeout)\n", + " await expect(page.locator('//*[@data-test-create-project-modal-button]')\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + "\n", + "async def wait_dashboard_loaded(page, retries=3):\n", + " \"\"\"ダッシュボードのプロジェクト一覧が読み終わるのを待つ。\n", + "\n", + " [新規プロジェクト作成] ボタンは一覧より先に出るので、それだけを待つと\n", + " 「読み込み中」と「プロジェクトが無い」を区別できない。読み込み中のまま\n", + " ensure_project_exists を呼ぶと「無い」と誤判定して同名プロジェクトを\n", + " 二重に作ってしまうので、必ずこれを先に通すこと。\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", + " 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", + " 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 wait_dashboard_loaded(page)\n", + " await page.locator(\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(\n", + " 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(\n", + " 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 wait_dashboard_loaded(page)\n", + " await scripts.grdm.ensure_project_exists(page, project_name,\n", + " transition_timeout)\n", + " await open_project(page, project_name)\n", + " await open_files_tab(page)\n", + "\n", + "\n", + "async def open_project_files(page, project_name):\n", + " \"\"\"既にあるプロジェクトを開いてファイルタブを出す(作らない)。\"\"\"\n", + " await open_project(page, project_name)\n", + " await open_files_tab(page)" + ], + "id": "cell-016", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [39] の複製。\n", + "# **phase b で呼んでよいのは S-10b-11(新規プロジェクト)だけ。**\n", + "# 既存プロジェクトに対して呼ぶと、develop 版が作った NodeSettings を\n", + "# 対象 PR 版で作り直してしまい、S-10 の意味が無くなる。\n", + "async def open_import_account_dialog(page, link, connect_btn, attempts=5):\n", + " \"\"\"[プロフィールからアカウントをインポート]を押し、[接続]が出るまで粘る。\n", + "\n", + " このリンクの click は knockout の `click: importAuth` バインド。アドオンを\n", + " 有効にした直後は mako がサーバ側で描画した HTML だけが先に見えていて\n", + " applyBindings がまだ走っていないことがあり、その状態で押すとクリックは\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", + " 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", + " アカウント(アクセスキー)はユーザー単位に保持されるので、2 回目以降は\n", + " 「アカウントに接続する」ではなく既存アカウントの選択になる。両方に対応する。\n", + " \"\"\"\n", + " await open_project(page, project_name)\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}\"]'\n", + " '//descendant::a[text() = \"有効にする\"]')\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 する。visible になった瞬間に押すと\n", + " # クリックが捨てられるので、ハンドラが付くまで待つ。\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", + " 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(\n", + " f'//*[@id = \"{addon_storage_id}Scope\"]'\n", + " '//a[contains(text(), \"プロフィールからアカウントをインポート\")]')\n", + " # [接続]は bootbox の確認ボタン。押し直しでダイアログが重なることが\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", + " 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\"]'\n", + " '//input[@id = \"access_key\"]').fill(s3_access_key)\n", + " await page.locator(\n", + " f'//*[@id = \"{addon_storage_id}InputCredentials\"]'\n", + " '//input[@id = \"secret_key\"]').fill(s3_secret_key)\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 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", + " 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", + " 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 を入れないと「正常にリンクされました」が出ない\n", + " await asyncio.sleep(1)\n", + " await page.locator(\n", + " f'//*[@class = \"{addon_storage_id}-confirm-selection\"]'\n", + " '//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(\n", + " timeout=30000)\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(\n", + " page, addon_storage_name)).to_be_visible(timeout=transition_timeout)\n", + " await asyncio.sleep(2)\n", + " print(f'{project_name}: {addon_storage_name} アドオンを接続した')" + ], + "id": "cell-017", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [40] の複製。\n", + "def listing_pages(entries=None, path_contains=None):\n", + " \"\"\"WB 応答から「フォルダ一覧(?meta=)」だけを抜き、next_token の連鎖に直す。\"\"\"\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\n", + "\n", + "\n", + "def list_versions(bucket, prefix, region=None):\n", + " \"\"\"回帰 NB セル [50] の複製。当該 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", + " 'ETag': v.get('ETag')}\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)}" + ], + "id": "cell-018", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# メタデータの採取と突合(この NB 固有。複製元なし)。\n", + "#\n", + "# **一覧(ListObjects)と単体取得(HEAD)は WaterButler の中で別のクラスが作る。**\n", + "# - 一覧 -> S3FileMetadata\n", + "# - 単体取得 -> S3FileMetadataHeaders\n", + "# 同じオブジェクトなのに片方だけ ETag に引用符が残る、という食い違いが起きうる\n", + "# ところなので、S-10 では**両方**を採って突き合わせる。\n", + "META_KEYS = ('etag', 'size', 'md5', 'version')\n", + "\n", + "\n", + "def _meta_of(attributes):\n", + " \"\"\"WB の attributes から比べたい値だけを抜く。\n", + "\n", + " `modified` は比較対象にしない(読むたびに変わるわけではないが、\n", + " develop 版と対象 PR 版で書式が違っても欠陥ではない)。\n", + " \"\"\"\n", + " extra = attributes.get('extra') or {}\n", + " hashes = extra.get('hashes') or {}\n", + " return {\n", + " 'name': attributes.get('name'),\n", + " 'path': attributes.get('path'),\n", + " 'materialized': attributes.get('materialized'),\n", + " 'kind': attributes.get('kind'),\n", + " 'size': attributes.get('size'),\n", + " 'etag': attributes.get('etag'),\n", + " 'md5': extra.get('md5'),\n", + " 'hashes_md5': hashes.get('md5'),\n", + " 'version': extra.get('version'),\n", + " 'modified': attributes.get('modified'),\n", + " }\n", + "\n", + "\n", + "def _unquote(value):\n", + " return value.replace('\"', '') if isinstance(value, str) else value\n", + "\n", + "\n", + "def has_quotes(value):\n", + " return isinstance(value, str) and '\"' in value\n", + "\n", + "\n", + "def wb_root_url(provider, entries=None):\n", + " \"\"\"直近の WB 応答から、今開いているプロジェクトの当該プロバイダのルート URL を作る。\n", + "\n", + " `https:///v1/resources//providers//` までを切り出す。\n", + " guid はプロジェクトごとに違うので、**対象のプロジェクトを開いた直後に**\n", + " 呼ぶこと(開く前に wb_responses を空にしておくと確実)。\n", + " \"\"\"\n", + " import re as _re\n", + " entries = (wb_responses or wb_all) if entries is None else entries\n", + " pat = _re.compile(r'^(https?://[^/]+/v1/resources/[^/]+/providers/'\n", + " + _re.escape(provider) + r'/)')\n", + " for e in reversed(entries):\n", + " m = pat.match(e['url'])\n", + " if m:\n", + " return m.group(1)\n", + " raise AssertionError(\n", + " f'WB の {provider} のルート URL が応答から見つからない。'\n", + " 'プロジェクトのファイルタブを開いてから呼ぶこと')\n", + "\n", + "\n", + "async def wb_get_json(page, url):\n", + " \"\"\"page の資格情報で WB を直接叩いて JSON を取る。\n", + "\n", + " page.request はブラウザのコンテキスト(= Cookie)を共有するので、\n", + " 画面を操作せずに `?meta=` を取れる。\n", + " \"\"\"\n", + " resp = await page.request.get(url, timeout=transition_timeout * 3)\n", + " body = await resp.text()\n", + " try:\n", + " return resp.status, json.loads(body)\n", + " except Exception:\n", + " return resp.status, {'_raw': body[:2000]}\n", + "\n", + "\n", + "def meta_url_of(link):\n", + " \"\"\"WB のファイル URL に `?meta=` を付ける。\"\"\"\n", + " return link + ('&' if '?' in link else '?') + 'meta='\n", + "\n", + "\n", + "async def fetch_listing(page, folder_url):\n", + " \"\"\"フォルダの一覧(?meta=)を取り、{name: (attributes, links)} にする。\n", + "\n", + " next_token が返る間は辿る(1000 件超のフォルダでも落ちないように)。\n", + " \"\"\"\n", + " items = {}\n", + " url = meta_url_of(folder_url)\n", + " pages = 0\n", + " while True:\n", + " status, body = await wb_get_json(page, url)\n", + " pages += 1\n", + " assert status == 200, f'一覧が {status}: {url}'\n", + " for d in body.get('data', []):\n", + " attrs = d.get('attributes', {})\n", + " items[attrs.get('materialized') or attrs.get('name')] = (\n", + " attrs, d.get('links', {}))\n", + " token = body.get('next_token')\n", + " if not token:\n", + " break\n", + " base = meta_url_of(folder_url)\n", + " url = f'{base}&next_token={token}'\n", + " return items, pages\n", + "\n", + "\n", + "async def collect_metadata(page, folder_url, recurse=True, _depth=0):\n", + " \"\"\"フォルダ以下の全ファイルの「一覧側」と「単体取得側」のメタデータを集める。\n", + "\n", + " 戻り値: {materialized_path: {'listing': {...}, 'headers': {...}}}\n", + " \"\"\"\n", + " out = {}\n", + " items, _ = await fetch_listing(page, folder_url)\n", + " for key, (attrs, links) in sorted(items.items()):\n", + " entry = {'listing': _meta_of(attrs)}\n", + " if attrs.get('kind') == 'folder':\n", + " if recurse and _depth < 5:\n", + " sub = links.get('upload') or links.get('new_folder')\n", + " if sub:\n", + " # upload リンクは `...?kind=file` なのでクエリを落とす\n", + " sub = sub.split('?')[0]\n", + " out.update(await collect_metadata(page, sub, recurse,\n", + " _depth + 1))\n", + " out[key] = entry\n", + " continue\n", + " # 単体取得。ここだけが S3FileMetadataHeaders を通る。\n", + " link = links.get('move') or links.get('download')\n", + " if link:\n", + " status, body = await wb_get_json(page, meta_url_of(link.split('?')[0]))\n", + " entry['headers_status'] = status\n", + " entry['headers'] = _meta_of(\n", + " (body.get('data') or {}).get('attributes', {}))\n", + " out[key] = entry\n", + " return out\n", + "\n", + "\n", + "def metadata_diff(before, after):\n", + " \"\"\"phase a と phase b のメタデータを突き合わせる。\n", + "\n", + " - `etag` / `md5` / `size` の不一致を出す(`etag` は引用符を落としてから比べる)\n", + " - **引用符が残っていること自体を差分として出す**(落としてから比べると\n", + " 隠れてしまうので、別の欄に記録する)\n", + " \"\"\"\n", + " diffs = []\n", + " for key in sorted(set(before) | set(after)):\n", + " b, a = before.get(key), after.get(key)\n", + " if b is None or a is None:\n", + " diffs.append({'key': key,\n", + " 'problem': 'missing',\n", + " 'in_before': b is not None,\n", + " 'in_after': a is not None})\n", + " continue\n", + " for side in ('listing', 'headers'):\n", + " bs, as_ = b.get(side) or {}, a.get(side) or {}\n", + " if not bs and not as_:\n", + " continue\n", + " for field in ('etag', 'md5', 'hashes_md5', 'size'):\n", + " bv, av = bs.get(field), as_.get(field)\n", + " if _unquote(bv) != _unquote(av):\n", + " diffs.append({'key': key, 'side': side, 'field': field,\n", + " 'before': bv, 'after': av})\n", + " if has_quotes(as_.get('etag')) or has_quotes(as_.get('md5')):\n", + " diffs.append({'key': key, 'side': side,\n", + " 'problem': 'quoted-etag',\n", + " 'etag': as_.get('etag'), 'md5': as_.get('md5')})\n", + " # 一覧側と単体取得側が互いに食い違っていないか(同じ段の中での突合)\n", + " la, ha = (a.get('listing') or {}), (a.get('headers') or {})\n", + " if ha and la.get('kind') != 'folder':\n", + " for field in ('etag', 'md5'):\n", + " if _unquote(la.get(field)) != _unquote(ha.get(field)):\n", + " diffs.append({'key': key, 'side': 'listing-vs-headers',\n", + " 'field': field,\n", + " 'listing': la.get(field),\n", + " 'headers': ha.get(field)})\n", + " return diffs\n", + "\n", + "\n", + "async def download_sha256(page, link):\n", + " \"\"\"WB からファイルを取り、SHA-256 と大きさを返す。\n", + "\n", + " `page.request.get` はリダイレクトを追うので、302 → presigned でも中身が\n", + " 取れる。302 そのものの確認は UI のツールバーからのダウンロード(後述)で\n", + " 別に採る。\n", + " \"\"\"\n", + " url = link.split('?')[0]\n", + " resp = await page.request.get(url, timeout=transition_timeout * 20)\n", + " body = await resp.body()\n", + " return {'status': resp.status, 'size': len(body),\n", + " 'sha256': sha256_bytes(body)}\n", + "\n", + "\n", + "async def collect_downloads(page, folder_url, recurse=True, _depth=0):\n", + " \"\"\"フォルダ以下の全ファイルを取り、SHA-256 を集める。\"\"\"\n", + " out = {}\n", + " items, _ = await fetch_listing(page, folder_url)\n", + " for key, (attrs, links) in sorted(items.items()):\n", + " if attrs.get('kind') == 'folder':\n", + " if recurse and _depth < 5:\n", + " sub = links.get('upload') or links.get('new_folder')\n", + " if sub:\n", + " out.update(await collect_downloads(page, sub.split('?')[0],\n", + " recurse, _depth + 1))\n", + " continue\n", + " link = links.get('download') or links.get('move')\n", + " if link:\n", + " out[key] = await download_sha256(page, link)\n", + " return out" + ], + "id": "cell-019", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# フォルダ作成。`取りまとめ-S3共通.ipynb` には fangorn の[新規フォルダ作成]の\n", + "# 手順が無かったので、`テスト手順-ストレージ共通-ファイル基本操作.ipynb` の\n", + "# セル [43] / [45] / [47] と同じ組み立てで新しく書いた。\n", + "async def create_folder(page, name):\n", + " \"\"\"今開いている行(ストレージ or フォルダ)の下に新規フォルダを作る。\n", + "\n", + " [新規フォルダ作成]は選択中の行に対して出るので、呼ぶ前に親の行を\n", + " クリックしておくこと。\n", + " \"\"\"\n", + " new_folder_btn = page.locator(\n", + " '//i[contains(@class, \"fa-plus\")]/../*[text() = \"新規フォルダ作成\"]')\n", + " await expect(new_folder_btn).to_be_enabled(timeout=transition_timeout)\n", + " await new_folder_btn.click()\n", + " folder_input = page.locator('#createFolderInput')\n", + " await expect(folder_input).to_be_editable(timeout=transition_timeout)\n", + " await folder_input.fill(name)\n", + " await folder_input.press('Enter')\n", + " title = grdm.get_select_folder_title_locator(page, name)\n", + " await title.scroll_into_view_if_needed()\n", + " await expect(title).to_be_visible(timeout=transition_timeout)\n", + " await asyncio.sleep(1)\n", + " print(f'フォルダを作成した: {name}')\n", + "\n", + "\n", + "# 回帰 NB セル [31] の複製(302 → presigned の採取)。\n", + "import re\n", + "\n", + "g10_redirects = []\n", + "\n", + "\n", + "def redirect_entry(url, status, loc):\n", + " return {\n", + " 'url': url,\n", + " 'status': status,\n", + " # `direct` 付きの要求は WB が自分でストリームする仕様なので 302 に\n", + " # ならない。「経路を踏んでいない」と「経路は踏んだが 302 にならない」を\n", + " # 取り違えないために必ず残す。\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", + " 応答レコーダ(上のセル)はヘッダを残さないので Location が取れない。\n", + " HAR もダウンロードのリダイレクト経路を落とすことがあるため、302 の\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", + " フォルダの「Zip 形式でダウンロード」も 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 upload_overwrite_and_wait(page, path):\n", + " \"\"\"同名で上書きアップロードする(回帰 NB セル [51] の複製)。\n", + "\n", + " 2 回目以降は fangorn が「置き換える / 保持する」を聞くので、出たら\n", + " [置き換える]を押す。押さないと仮行が残り、Dropzone は\n", + " parallelUploads: 1 なので次のアップロードがキューで止まる。\n", + " \"\"\"\n", + " name = os.path.basename(path)\n", + " await clear_growls(page)\n", + " await grdm.upload_file(page, path)\n", + " await wait_for_upload_finished(page, name)\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, name)\n", + " await asyncio.sleep(2)\n", + " return name\n", + "\n", + "\n", + "async def delete_file_from_toolbar(page, filename):\n", + " \"\"\"ファイル一覧のツールバーの[削除]で消す(回帰 NB セル [54] の複製)。\"\"\"\n", + " await grdm.get_select_file_extension_locator(page, filename).click(\n", + " timeout=transition_timeout)\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", + " 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, filename)\n", + " ).to_have_count(0, timeout=transition_timeout * 3)\n", + " print('growl:', await growl_messages(page))\n", + "\n", + "\n", + "async def download_from_toolbar(page, filename):\n", + " \"\"\"一覧のツールバーからダウンロードして保存先を返す。\n", + "\n", + " 詳細画面の[ダウンロード]は OSF が `direct` を付けるので 302 にならない。\n", + " presigned への 302 を採るには一覧のツールバーを使うこと。\n", + " \"\"\"\n", + " download_dir = os.path.join(work_dir, 'downloaded')\n", + " os.makedirs(download_dir, exist_ok=True)\n", + " await grdm.get_select_file_extension_locator(page, filename).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 * 10) as info:\n", + " await btn.click(timeout=transition_timeout)\n", + " download = await info.value\n", + " path = os.path.join(download_dir, download.suggested_filename)\n", + " await download.save_as(path)\n", + " await asyncio.sleep(2)\n", + " return path" + ], + "id": "cell-020", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## phase a: 仕込み(**試験環境は develop 版**)\n", + "\n", + "旧実装で機関ストレージを登録し、S3 アドオンを接続し、データを置く。\n", + "置いたものの**メタデータとダウンロードの SHA-256 を控える**のがこの段の目的で、\n", + "突合は phase b で行う。\n", + "\n", + "ここから `phase == 'a'` のセルだけが動く。phase b / c では全部スキップされる。\n", + "\n", + "置くもの:\n", + "\n", + "| 置き場所 | ファイル | 何を見たいか |\n", + "|---|---|---|\n", + "| 機関ストレージ(`*-inst`) | `S-10-ascii.txt`、`S-10-日本語.txt` | 機関ストレージ経由の読み書き |\n", + "| S3 アドオン(`*-addon`) | `S-10-ascii.txt`、`S-10-日本語 名前+記号(1).txt`、`S-10 space.txt`、`S-10-folder/サブ/nested.txt`、boto3 で直接置く `S-10-raw/a+b %25 c~d.txt`、200MB | 署名の対象になる文字、2 階層、GRDM 外から置かれたキー、マルチパートの ETag |\n", + "| S3 アドオン(`*-ver`、バージョニング有効) | `S-10-ver.txt` を 3 版 | バージョン一覧と削除 |\n", + "| (任意)S3 アドオン(`*-dot`) | `S-10-ascii.txt` | ドット入りバケット名 |" + ], + "id": "cell-021" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a':\n", + " async def _step(page):\n", + " await admin_login(page)\n", + " await goto_institutional_storage(page)\n", + " await select_institution(page)\n", + " before = await capture_institutional_storage_state(\n", + " page, 'S-10a-institutional-storage-before')\n", + " print('登録前:', before)\n", + " state['institutional_before'] = before\n", + " await register_institutional_s3(page, s3_bucket_mig, 'S-10a')\n", + " await admin_logout(page)\n", + "\n", + " await run_pw(_step)\n", + " save_state(state)\n", + "else:\n", + " print(f'phase={phase} のためスキップ(機関ストレージは phase a でしか触らない)')" + ], + "id": "cell-022", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# ユーザ画面へ。phase b / c でも使うので、ここは phase で分けない。\n", + "async def _step(page):\n", + " await user_login(page)\n", + " await capture_wb_redirects(page, g10_redirects)\n", + "\n", + "await run_pw(_step)" + ], + "id": "cell-023", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a':\n", + " # S-10a: 機関ストレージのプロジェクトに 2 件置く。\n", + " _inst_files = {}\n", + " for _name, _body in (\n", + " (f_ascii, f'S-10 institutional ascii\\nrun_id: {run_id}\\n'),\n", + " (f_japanese, f'S-10 機関ストレージ 日本語\\nrun_id: {run_id}\\n')):\n", + " _p = os.path.join(work_dir, _name)\n", + " with open(_p, 'w') as _f:\n", + " _f.write(_body)\n", + " _inst_files[_name] = _p\n", + "\n", + " async def _step(page):\n", + " await create_project_and_open_files(page, p_inst)\n", + " wb_responses.clear()\n", + " await open_institutional_storage(page)\n", + " for _n, _p in _inst_files.items():\n", + " await upload_and_wait(page, _p)\n", + "\n", + " await run_pw(_step)\n", + " keep_all('S-10a-inst-upload.json')\n", + " for _n, _p in _inst_files.items():\n", + " state['files'][f'inst/{_n}'] = {\n", + " 'where': 'inst', 'name': _n,\n", + " 'sha256': sha256_file(_p), 'size': os.path.getsize(_p)}\n", + " save_state(state)\n", + " print(json.dumps(state['files'], indent=2, ensure_ascii=False))" + ], + "id": "cell-024", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a':\n", + " # S-10a: アドオンのプロジェクトを作り、develop 版で接続する。\n", + " async def _step(page):\n", + " await create_project_and_open_files(page, p_addon)\n", + " await connect_s3_addon(page, p_addon, s3_bucket_mig)\n", + "\n", + " await run_pw(_step)\n", + " keep_all('S-10a-addon-connect.json')" + ], + "id": "cell-025", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a':\n", + " # S-10a: 名前に意味のあるファイルを置く。\n", + " # SigV2 と SigV4 でエンコードの規則が変わるので、空白・`+`・括弧・日本語を\n", + " # 含む名前をわざと使う。\n", + " _addon_files = {}\n", + " for _name in (f_ascii, f_symbols, f_space):\n", + " _p = os.path.join(work_dir, _name)\n", + " with open(_p, 'w') as _f:\n", + " _f.write(f'S-10 addon {_name}\\nrun_id: {run_id}\\n')\n", + " _addon_files[_name] = _p\n", + "\n", + " _nested_path = os.path.join(work_dir, f_nested)\n", + " with open(_nested_path, 'w') as _f:\n", + " _f.write(f'S-10 nested\\nrun_id: {run_id}\\n')\n", + "\n", + " async def _step(page):\n", + " await open_project_files(page, p_addon)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " for _n, _p in _addon_files.items():\n", + " await upload_and_wait(page, _p)\n", + " # 2 階層のフォルダを作り、底にファイルを置く\n", + " await grdm.get_select_storage_title_locator(\n", + " page, addon_storage_name).click(timeout=transition_timeout)\n", + " await create_folder(page, folder_top)\n", + " await grdm.get_select_folder_title_locator(\n", + " page, folder_top).click(timeout=transition_timeout)\n", + " await create_folder(page, folder_sub)\n", + " await grdm.get_select_folder_title_locator(\n", + " page, folder_sub).click(timeout=transition_timeout)\n", + " await upload_and_wait(page, _nested_path)\n", + "\n", + " await run_pw(_step)\n", + " keep_all('S-10a-addon-upload.json')\n", + "\n", + " for _n, _p in _addon_files.items():\n", + " state['files'][f'addon/{_n}'] = {\n", + " 'where': 'addon', 'name': _n,\n", + " 'sha256': sha256_file(_p), 'size': os.path.getsize(_p)}\n", + " state['files'][f'addon/{folder_top}/{folder_sub}/{f_nested}'] = {\n", + " 'where': 'addon', 'name': f_nested,\n", + " 'folder': f'{folder_top}/{folder_sub}',\n", + " 'sha256': sha256_file(_nested_path),\n", + " 'size': os.path.getsize(_nested_path)}\n", + " save_state(state)" + ], + "id": "cell-026", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a':\n", + " # S-10a: GRDM の外から置かれたキーの代表。`+`・空白・`%25`・`~` を含む。\n", + " # GRDM の画面からは作れない名前なので boto3 で直接置く。\n", + " _raw_body = f'S-10 raw key\\nrun_id: {run_id}\\n'.encode()\n", + " s3_client(s3_region_default).put_object(\n", + " Bucket=s3_bucket_mig, Key=f_raw_key, Body=_raw_body)\n", + " state['files'][f'addon/{f_raw_key}'] = {\n", + " 'where': 'addon', 'name': os.path.basename(f_raw_key),\n", + " 'key': f_raw_key, 'put_by': 'boto3',\n", + " 'sha256': sha256_bytes(_raw_body), 'size': len(_raw_body)}\n", + " save_state(state)\n", + " print(f'boto3 で直接置いた: {f_raw_key} ({len(_raw_body)} bytes)')" + ], + "id": "cell-027", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a' and run_chunked:\n", + " # S-10a: 200MB。develop 版(boto)のマルチパートで上がるので ETag が `-N` になる。\n", + " # N は phase b で一致を見るので控える。\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", + " '単発 PUT になり、マルチパートの ETag にならない')\n", + " _chunk_path = os.path.join(work_dir, f_chunked)\n", + " with open(_chunk_path, 'wb') as _f:\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(_chunk_path, os.path.getsize(_chunk_path), 'bytes')\n", + "\n", + " async def _step(page):\n", + " await open_project_files(page, p_addon)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " await upload_and_wait(page, _chunk_path)\n", + "\n", + " await run_pw(_step)\n", + " keep_all('S-10a-chunked-upload.json')\n", + "\n", + " _h = s3_client(s3_region_default).head_object(\n", + " Bucket=s3_bucket_mig, Key=f_chunked)\n", + " _etag = _h['ETag'].strip('\"')\n", + " state['files'][f'addon/{f_chunked}'] = {\n", + " 'where': 'addon', 'name': f_chunked,\n", + " 'sha256': sha256_file(_chunk_path), 'size': os.path.getsize(_chunk_path)}\n", + " state['expected']['chunked'] = {\n", + " 'etag_raw': _h['ETag'], 'etag': _etag,\n", + " 'multipart': '-' in _etag,\n", + " 'part_count': int(_etag.split('-')[1]) if '-' in _etag else None,\n", + " 'ContentLength': _h['ContentLength'],\n", + " }\n", + " save_state(state)\n", + " record_json('S-10a-chunked.json', state['expected']['chunked'], quiet=False)\n", + " assert state['expected']['chunked']['multipart'], (\n", + " f'ETag にパート数の接尾辞が無い({_etag})。単発 PUT で上がっている。'\n", + " 'chunked_file_size を試験環境の CONTIGUOUS_UPLOAD_SIZE_LIMIT 以上にすること')\n", + "elif phase == 'a':\n", + " print('run_chunked=False のためスキップ')" + ], + "id": "cell-028", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a':\n", + " # S-10a: バージョニング有効バケットに 3 版作る。\n", + " _ver_path = os.path.join(work_dir, f_ver)\n", + "\n", + " async def _step(page):\n", + " await create_project_and_open_files(page, p_ver)\n", + " await connect_s3_addon(page, p_ver, s3_bucket_migver)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " for _i in (1, 2, 3):\n", + " with open(_ver_path, 'w') as _f:\n", + " _f.write(f'S-10 version {_i}\\nrun_id: {run_id}\\n')\n", + " _f.write('x' * (100 * _i))\n", + " await upload_overwrite_and_wait(page, _ver_path)\n", + " print(f' {_i} 回目のアップロード完了')\n", + " await expect(grdm.get_select_file_title_locator(page, f_ver)\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + " await run_pw(_step)\n", + " keep_all('S-10a-ver-upload.json')\n", + "\n", + " _v = list_versions(s3_bucket_migver, f_ver)\n", + " record_json('S-10a-versions.json', _v, quiet=False)\n", + " state['files'][f'ver/{f_ver}'] = {\n", + " 'where': 'ver', 'name': f_ver,\n", + " 'sha256': sha256_file(_ver_path), 'size': os.path.getsize(_ver_path),\n", + " 'version_count': _v['version_count']}\n", + " save_state(state)\n", + " assert _v['version_count'] >= 2, (\n", + " f'{f_ver} の版が {_v[\"version_count\"]} 件しかない。'\n", + " 'バージョニングが効いていないか、上書きが 1 回も成立していない')" + ], + "id": "cell-029", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a' and run_dotted:\n", + " # S-10a(任意): ドット入りバケット名。**失敗しても止めない。**\n", + " # develop 版でも通らないなら、phase b の S-10b-10 は比較対象なしとして扱う。\n", + " _dot_path = os.path.join(work_dir, f_ascii)\n", + " _dot_result = {'bucket': s3_bucket_dot, 'project': p_dot}\n", + " try:\n", + " async def _step(page):\n", + " await create_project_and_open_files(page, p_dot)\n", + " await connect_s3_addon(page, p_dot, s3_bucket_dot)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " await upload_and_wait(page, _dot_path)\n", + "\n", + " await run_pw(_step)\n", + " _dot_result['ok'] = True\n", + " except Exception as _e:\n", + " _dot_result['ok'] = False\n", + " _dot_result['error'] = repr(_e)\n", + " print(f'[!] ドット入りバケットの接続/アップロードが失敗した: {_e!r}')\n", + " print(' 記録して続行する(S-10b-10 は比較対象なしとして扱う)')\n", + " _dot_result['wb_failures'] = [\n", + " {'status': e['status'], 'url': e['url']} for e in wb_failures()]\n", + " keep_all('S-10a-dot-wb.json')\n", + " record_json('S-10a-dot.json', _dot_result, quiet=False)\n", + " state['dot'] = _dot_result\n", + " save_state(state)\n", + "elif phase == 'a':\n", + " print('run_dotted=False のためスキップ')" + ], + "id": "cell-030", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a':\n", + " # S-10a: 置いたもの全部のメタデータとダウンロード SHA-256 を控える。\n", + " # phase b はここで採った値と突き合わせる。\n", + " a_meta = {}\n", + " a_down = {}\n", + "\n", + " async def _step(page):\n", + " global a_meta, a_down\n", + " # 機関ストレージ(osfstorage 経由)\n", + " await open_project_files(page, p_inst)\n", + " wb_responses.clear()\n", + " await open_institutional_storage(page)\n", + " _root = wb_root_url('osfstorage')\n", + " a_meta['inst'] = await collect_metadata(page, _root)\n", + " a_down['inst'] = await collect_downloads(page, _root)\n", + " # S3 アドオン\n", + " await open_project_files(page, p_addon)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " _root = wb_root_url(addon_storage_id)\n", + " a_meta['addon'] = await collect_metadata(page, _root)\n", + " a_down['addon'] = await collect_downloads(page, _root)\n", + " # バージョン付き\n", + " await open_project_files(page, p_ver)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " _root = wb_root_url(addon_storage_id)\n", + " a_meta['ver'] = await collect_metadata(page, _root)\n", + " a_down['ver'] = await collect_downloads(page, _root)\n", + "\n", + " await run_pw(_step)\n", + " keep_all('S-10a-metadata-fetch.json')\n", + " record_json('S-10a-metadata.json', a_meta)\n", + " record_json('S-10a-downloads.json', a_down)\n", + " state['metadata_a'] = a_meta\n", + " state['downloads_a'] = a_down\n", + " save_state(state)\n", + " print('採ったキー:')\n", + " for _scope, _items in a_meta.items():\n", + " print(f' {_scope}: {sorted(_items)}')" + ], + "id": "cell-031", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'a':\n", + " # S-10a の判定。ここは「仕込みが成立したか」だけを見る。\n", + " _bad = wb_failures(wb_all)\n", + " _verdict = [\n", + " f'phase: a (run_id {run_id})',\n", + " f'機関ストレージ: {s3_bucket_mig}',\n", + " f'アドオン(通常): {s3_bucket_mig} / プロジェクト {p_addon}',\n", + " f'アドオン(バージョン): {s3_bucket_migver} / プロジェクト {p_ver}',\n", + " f'ドット入り: {s3_bucket_dot}',\n", + " f'置いたファイル: {len(state[\"files\"])} 件',\n", + " f'WB 応答の 4xx/5xx・署名系の文言: {len(_bad)} 件',\n", + " ]\n", + " for _e in _bad:\n", + " _verdict.append(f' - {_e[\"status\"]} {_e[\"method\"]} {_e[\"url\"]}')\n", + " record('S-10a-verdict.txt', '\\n'.join(_verdict))\n", + " record_json('S-10a-wb-all.json', json.loads(_redact(\n", + " json.dumps(wb_all, ensure_ascii=False))))\n", + " assert not _bad, 'phase a の時点で WB 応答に失敗がある。仕込みが成立していない'\n", + " print()\n", + " print('=' * 74)\n", + " print(' phase a 完了。次にすること')\n", + " print('=' * 74)\n", + " print(' 1. 試験環境を **対象 PR 版** に切り替える')\n", + " print(' 2. このノートブックを phase=\"b\" で実行する。そのとき')\n", + " print(f' state_path=\"{state_path}\" を必ず渡すこと')\n", + " print(' 3. バケットもプロジェクトも消さないこと')\n", + " print('=' * 74)" + ], + "id": "cell-032", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## phase b: 検証(**試験環境は対象 PR 版**)\n", + "\n", + "phase a が develop 版で作った設定とデータを、**設定を一切触らずに**開く。\n", + "\n", + "### 呼んではいけないもの\n", + "\n", + "- `register_institutional_s3()` — 呼ぶと機関ストレージ設定が対象 PR 版で\n", + " 作り直され、「develop 版が作った設定を読めるか」が確かめられなくなる\n", + "- `connect_s3_addon()` — 同じく `NodeSettings` が作り直される。\n", + " **S-10b-11 の新規プロジェクトに対してだけ**呼ぶ\n", + "\n", + "判定は 1 項目ずつ結果を溜めてから最後にまとめて `assert` する。\n", + "**途中の 1 件で例外を投げて止めない**(残りの項目の結果が取れなくなるため)。\n", + "\n", + "| 番号 | 見るもの |\n", + "|---|---|\n", + "| S-10b-1 | 機関ストレージ設定の表示 |\n", + "| S-10b-2 | `*-inst` の一覧・ダウンロード・302 |\n", + "| S-10b-3 | `*-addon` の一覧(フォルダ展開を含む) |\n", + "| S-10b-4 | `?meta=`(単体取得)と一覧の突合、**ETag の引用符** |\n", + "| S-10b-5 | 全ファイルのダウンロード SHA-256 |\n", + "| S-10b-6 | intra move → copy |\n", + "| S-10b-7 | バージョン付きファイルの UI 削除 |\n", + "| S-10b-8 | 新データ投入(phase c 用) |\n", + "| S-10b-9 | WB 応答に 4xx/5xx と署名系の文言が無いこと |\n", + "| S-10b-10(任意) | ドット入りバケット |\n", + "| S-10b-11 | 新規接続と `folder_id` の保存形 |" + ], + "id": "cell-033" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 判定の控え。各セルがここに結果を足し、最後のセルでまとめて判定する。\n", + "b_results = {}\n", + "\n", + "\n", + "def b_record(num, ok, detail):\n", + " b_results[num] = {'ok': bool(ok), 'detail': detail}\n", + " print(f'{num}: {\"PASS\" if ok else \"FAIL\"} — {detail}')\n", + " return ok" + ], + "id": "cell-034", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'b':\n", + " # S-10b-1: 機関ストレージ設定を**表示だけ**する。保存ボタンは押さない。\n", + " #\n", + " # `run_pw` に渡す関数は**値を返さないこと**。返した値は「新しいページ」と\n", + " # みなされて Playwright のページ一覧に積まれる(scripts/playwright.py の\n", + " # `next_page`)。結果は外の変数に入れる。\n", + " _b1 = {}\n", + "\n", + " async def _step(page):\n", + " await admin_login(page)\n", + " await goto_institutional_storage(page)\n", + " await select_institution(page)\n", + " _b1['after'] = await capture_institutional_storage_state(\n", + " page, 'S-10b-institutional-storage')\n", + " await admin_logout(page)\n", + "\n", + " await run_pw(_step)\n", + " _after = _b1.get('after') or {}\n", + " _before = state.get('institutional_before') or {}\n", + " _same_provider = (_after or {}).get('selected') == 's3'\n", + " _diff = {k: {'phase_a': _before.get(k), 'phase_b': (_after or {}).get(k)}\n", + " for k in set(_before) | set(_after or {})\n", + " if _before.get(k) != (_after or {}).get(k)}\n", + " record_json('S-10b-institutional-storage-diff.json',\n", + " {'phase_a_before_register': _before, 'phase_b': _after,\n", + " 'diff': _diff}, quiet=False)\n", + " b_record('S-10b-1', _same_provider,\n", + " f'選択は {(_after or {}).get(\"selected\")} / 表示名 '\n", + " f'{(_after or {}).get(\"storage_name\")!r} / 差分 {len(_diff)} 件')" + ], + "id": "cell-035", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'b':\n", + " # S-10b-2 / 3 / 4 / 5: 一覧・単体取得・ダウンロードを一度に採る。\n", + " # phase a の控えと比べるのは次のセル。\n", + " b_meta = {}\n", + " b_down = {}\n", + " b_listing = {}\n", + " b_toolbar = {}\n", + "\n", + " async def _step(page):\n", + " # 302 → presigned を踏ませる代表ファイルは段ごとに違う\n", + " # (*-ver に S-10-ascii.txt は無い)。\n", + " for _scope, _project, _provider, _opener, _rep in (\n", + " ('inst', p_inst, 'osfstorage', open_institutional_storage,\n", + " f_ascii),\n", + " ('addon', p_addon, addon_storage_id, None, f_ascii),\n", + " ('ver', p_ver, addon_storage_id, None, f_ver)):\n", + " await open_project_files(page, _project)\n", + " wb_responses.clear()\n", + " if _opener is None:\n", + " await open_storage(page, addon_storage_name)\n", + " else:\n", + " await _opener(page)\n", + " _root = wb_root_url(_provider)\n", + " b_listing[_scope] = listing_pages()\n", + " b_meta[_scope] = await collect_metadata(page, _root)\n", + " b_down[_scope] = await collect_downloads(page, _root)\n", + " # 302 → presigned は UI のツールバーからのダウンロードでしか出ない。\n", + " # 代表 1 件だけ踏む。\n", + " try:\n", + " _path = await download_from_toolbar(page, _rep)\n", + " b_toolbar[_scope] = {'file': _rep,\n", + " 'saved': os.path.basename(_path),\n", + " 'sha256': sha256_file(_path),\n", + " 'size': os.path.getsize(_path)}\n", + " except Exception as _e:\n", + " b_toolbar[_scope] = {'file': _rep, 'error': repr(_e)}\n", + "\n", + " await run_pw(_step)\n", + " keep_all('S-10b-fetch.json')\n", + " record_json('S-10b-listing.json', b_listing)\n", + " record_json('S-10b-metadata.json', b_meta)\n", + " record_json('S-10b-downloads.json', b_down)\n", + " record_json('S-10b-toolbar-downloads.json', b_toolbar, quiet=False)" + ], + "id": "cell-036", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'b':\n", + " # S-10b-3: phase a で置いたキーが欠落・文字化けなく出ているか。\n", + " _a_meta = state.get('metadata_a', {})\n", + " _missing = {}\n", + " for _scope, _items in _a_meta.items():\n", + " _now = set(b_meta.get(_scope, {}))\n", + " _missing[_scope] = sorted(set(_items) - _now)\n", + " _extra = {_scope: sorted(set(b_meta.get(_scope, {})) - set(_items))\n", + " for _scope, _items in _a_meta.items()}\n", + " record_json('S-10b-listing-diff.json',\n", + " {'missing': _missing, 'extra': _extra}, quiet=False)\n", + " b_record('S-10b-3', not any(_missing.values()),\n", + " f'欠落 {sum(len(v) for v in _missing.values())} 件 / '\n", + " f'phase a に無かったもの {sum(len(v) for v in _extra.values())} 件')\n", + "\n", + " # S-10b-4: メタデータの突合。引用符付き ETag はそれ自体を差分として出す。\n", + " _diffs = {}\n", + " for _scope, _items in _a_meta.items():\n", + " _diffs[_scope] = metadata_diff(_items, b_meta.get(_scope, {}))\n", + " record_json('S-10b-metadata-diff.json', _diffs, quiet=False)\n", + " _quoted = [d for ds in _diffs.values() for d in ds\n", + " if d.get('problem') == 'quoted-etag']\n", + " _n_diff = sum(len(v) for v in _diffs.values())\n", + " b_record('S-10b-4', _n_diff == 0,\n", + " f'差分 {_n_diff} 件(うち引用符付き ETag {len(_quoted)} 件)')\n", + "\n", + " # 200MB のパート数。\n", + " if run_chunked and state.get('expected', {}).get('chunked'):\n", + " _exp = state['expected']['chunked']\n", + " _h = s3_client(s3_region_default).head_object(\n", + " Bucket=s3_bucket_mig, Key=f_chunked)\n", + " _now_etag = _h['ETag'].strip('\"')\n", + " record_json('S-10b-chunked.json',\n", + " {'phase_a': _exp, 'phase_b_etag_raw': _h['ETag'],\n", + " 'phase_b_etag': _now_etag}, quiet=False)\n", + " b_record('S-10b-4-multipart', _now_etag == _exp['etag'],\n", + " f'S3 側の ETag {_now_etag} / phase a {_exp[\"etag\"]}')\n", + "\n", + " # S-10b-5: ダウンロードの SHA-256。\n", + " _a_down = state.get('downloads_a', {})\n", + " _mismatch = []\n", + " for _scope, _items in _a_down.items():\n", + " for _key, _before in _items.items():\n", + " _now = (b_down.get(_scope) or {}).get(_key)\n", + " if _now is None:\n", + " _mismatch.append({'scope': _scope, 'key': _key,\n", + " 'problem': 'not-downloadable'})\n", + " elif _now['sha256'] != _before['sha256']:\n", + " _mismatch.append({'scope': _scope, 'key': _key,\n", + " 'before': _before, 'after': _now})\n", + " record_json('S-10b-downloads-diff.json', _mismatch, quiet=False)\n", + " b_record('S-10b-5', not _mismatch, f'不一致 {len(_mismatch)} 件')\n", + "\n", + " # S-10b-2: 機関ストレージの一覧とダウンロード、302 → presigned。\n", + " _inst_ok = (not _missing.get('inst') and\n", + " not [m for m in _mismatch if m['scope'] == 'inst'])\n", + " _presigned = [r for r in g10_redirects\n", + " if r['status'] == 302 and r['location_is_amazonaws']\n", + " and r['location_has_signature']]\n", + " record_json('S-10b-download-redirect.json',\n", + " {'redirects': g10_redirects,\n", + " 'presigned_302': len(_presigned)}, quiet=False)\n", + " b_record('S-10b-2', _inst_ok and bool(_presigned),\n", + " f'機関ストレージ一覧/DL {\"OK\" if _inst_ok else \"NG\"} / '\n", + " f'presigned への 302 {len(_presigned)} 件')" + ], + "id": "cell-037", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'b':\n", + " # S-10b-6: intra move → copy。移動して戻す。\n", + " # 成功の知らせは growl ではなく treebeard の行 notify で出る\n", + " # (回帰 NB セル [59] と同じ理由)。表示は 1 秒で消えるので待ち方を変えない。\n", + " 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` を立てる。mouse.up のあとに離すこと。\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", + " async def wait_file_op_notified(page, word):\n", + " try:\n", + " await expect(page.locator(\n", + " 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", + " b_intra = {}\n", + "\n", + " async def _step(page):\n", + " await open_project_files(page, p_addon)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " await clear_growls(page)\n", + " # 移動: 素のドラッグ。対象は phase a で置いた記号入りの名前。\n", + " await grdm.get_select_file_extension_locator(page, f_symbols).click(\n", + " timeout=transition_timeout)\n", + " src = grdm.get_select_file_draggable_locator(page, f_symbols)\n", + " dst = grdm.get_select_folder_droppable_locator(page, folder_top)\n", + " await grdm.drag_and_drop(page, src, dst)\n", + " b_intra['move'] = {'notified': await wait_file_op_notified(page, '移動')}\n", + " await asyncio.sleep(3)\n", + " b_intra['move'].update({\n", + " 'growls': await growl_messages(page),\n", + " 'wb': [e for e in wb_responses if e['method'] == 'POST']})\n", + " # コピー: Alt + ドラッグで元の場所に戻す\n", + " wb_responses.clear()\n", + " await clear_growls(page)\n", + " await grdm.get_select_folder_title_locator(page, folder_top).click(\n", + " timeout=transition_timeout)\n", + " await asyncio.sleep(2)\n", + " await grdm.get_select_file_extension_locator(page, f_symbols).click(\n", + " timeout=transition_timeout)\n", + " src = grdm.get_select_file_draggable_locator(page, f_symbols)\n", + " dst = grdm.get_select_storage_title_locator(page, addon_storage_name)\n", + " await drag_and_drop_with_alt(page, src, dst)\n", + " b_intra['copy'] = {'notified': await wait_file_op_notified(page, 'コピー')}\n", + " await asyncio.sleep(3)\n", + " b_intra['copy'].update({\n", + " 'growls': await growl_messages(page),\n", + " 'wb': [e for e in wb_responses if e['method'] == 'POST']})\n", + "\n", + " try:\n", + " await run_pw(_step)\n", + " except Exception as _e:\n", + " b_intra['error'] = repr(_e)\n", + " print(f'[!] intra copy/move で例外: {_e!r}(記録して続行)')\n", + " keep_all('S-10b-intra-wb.json')\n", + "\n", + " # 画面の知らせだけでは足りないので S3 の実体でも確かめる。\n", + " _c = s3_client(s3_region_default)\n", + " for _label, _key in (('moved', f'{folder_top}/{f_symbols}'),\n", + " ('copied_back', f_symbols)):\n", + " try:\n", + " _h = _c.head_object(Bucket=s3_bucket_mig, Key=_key)\n", + " b_intra[_label] = {'key': _key, 'ContentLength': _h['ContentLength'],\n", + " 'ETag': _h['ETag']}\n", + " except Exception as _e:\n", + " b_intra[_label] = {'key': _key, 'error': repr(_e)}\n", + " record_json('S-10b-intra.json', b_intra, quiet=False)\n", + " _ok = (b_intra.get('move', {}).get('wb')\n", + " and b_intra.get('copy', {}).get('wb')\n", + " and 'error' not in b_intra.get('moved', {})\n", + " and 'error' not in b_intra.get('copied_back', {}))\n", + " b_record('S-10b-6', bool(_ok),\n", + " f'move POST {len(b_intra.get(\"move\", {}).get(\"wb\", []))} 本 / '\n", + " f'copy POST {len(b_intra.get(\"copy\", {}).get(\"wb\", []))} 本')" + ], + "id": "cell-038", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'b':\n", + " # S-10b-7: バージョン付きファイルを UI から削除し、S3 側で版が残らないことを見る。\n", + " _before = list_versions(s3_bucket_migver, f_ver)\n", + " record_json('S-10b-versions-before.json', _before, quiet=False)\n", + "\n", + " async def _step(page):\n", + " await open_project_files(page, p_ver)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " await delete_file_from_toolbar(page, f_ver)\n", + " # 削除は非同期ではないが、S3 の一覧が追いつくまで少し待つ\n", + " await asyncio.sleep(5)\n", + "\n", + " try:\n", + " await run_pw(_step)\n", + " _err = None\n", + " except Exception as _e:\n", + " _err = repr(_e)\n", + " print(f'[!] 削除操作で例外: {_e!r}(記録して続行)')\n", + " keep_all('S-10b-versions-wb.json')\n", + "\n", + " _after = list_versions(s3_bucket_migver, f_ver)\n", + " record_json('S-10b-versions-after.json', _after, quiet=False)\n", + " b_record('S-10b-7',\n", + " _after['version_count'] == 0 and _after['delete_marker_count'] == 0,\n", + " f'削除前 版 {_before[\"version_count\"]} / 削除後 版 '\n", + " f'{_after[\"version_count\"]} + 削除マーカー '\n", + " f'{_after[\"delete_marker_count\"]}'\n", + " + (f' / 例外 {_err}' if _err else ''))" + ], + "id": "cell-039", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'b':\n", + " # S-10b-8: phase c で読む新データを対象 PR 版で置く。\n", + " _new_files = {}\n", + " _p = os.path.join(work_dir, f_new_japanese)\n", + " with open(_p, 'w') as _f:\n", + " _f.write(f'S-10b new 日本語\\nrun_id: {run_id}\\n')\n", + " _new_files[f_new_japanese] = _p\n", + "\n", + " if run_chunked:\n", + " _p = os.path.join(work_dir, f_new_chunked)\n", + " with open(_p, 'wb') as _f:\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", + " _new_files[f_new_chunked] = _p\n", + "\n", + " _new_ver_path = os.path.join(work_dir, f_new_ver)\n", + "\n", + " async def _step(page):\n", + " await open_project_files(page, p_addon)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " for _n, _path in _new_files.items():\n", + " await upload_and_wait(page, _path)\n", + " # バージョン付きバケットに別名で 3 版\n", + " await open_project_files(page, p_ver)\n", + " await open_storage(page, addon_storage_name)\n", + " for _i in (1, 2, 3):\n", + " with open(_new_ver_path, 'w') as _f:\n", + " _f.write(f'S-10b version {_i}\\nrun_id: {run_id}\\n')\n", + " _f.write('x' * (100 * _i))\n", + " await upload_overwrite_and_wait(page, _new_ver_path)\n", + "\n", + " await run_pw(_step)\n", + " keep_all('S-10b-new-upload.json')\n", + "\n", + " for _n, _path in _new_files.items():\n", + " state['files'][f'addon/{_n}'] = {\n", + " 'where': 'addon', 'name': _n, 'placed_in_phase': 'b',\n", + " 'sha256': sha256_file(_path), 'size': os.path.getsize(_path)}\n", + " state['files'][f'ver/{f_new_ver}'] = {\n", + " 'where': 'ver', 'name': f_new_ver, 'placed_in_phase': 'b',\n", + " 'sha256': sha256_file(_new_ver_path),\n", + " 'size': os.path.getsize(_new_ver_path)}\n", + " _nv = list_versions(s3_bucket_migver, f_new_ver)\n", + " state['files'][f'ver/{f_new_ver}']['version_count'] = _nv['version_count']\n", + " record_json('S-10b-new-versions.json', _nv, quiet=False)\n", + "\n", + " # 置いた直後のメタデータも控える(phase c がこれと比べる)。\n", + " async def _step2(page):\n", + " global _new_meta, _new_down\n", + " await open_project_files(page, p_addon)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " _root = wb_root_url(addon_storage_id)\n", + " _new_meta = await collect_metadata(page, _root)\n", + " _new_down = await collect_downloads(page, _root)\n", + "\n", + " _new_meta, _new_down = {}, {}\n", + " await run_pw(_step2)\n", + " keep_all('S-10b-new-fetch.json')\n", + " record_json('S-10b-new-metadata.json', _new_meta)\n", + " record_json('S-10b-new-downloads.json', _new_down)\n", + " state['metadata_b_new'] = _new_meta\n", + " state['downloads_b_new'] = _new_down\n", + " save_state(state)\n", + " # _new_down のキーは WB の materialized パスなので basename で引き当てる。\n", + " _new_by_name = {os.path.basename(k.rstrip('/')) for k in _new_down}\n", + " _not_placed = [n for n in _new_files if n not in _new_by_name]\n", + " b_record('S-10b-8', not _not_placed and _nv['version_count'] >= 2,\n", + " f'新データ {len(_new_files)} 件中 置けなかったもの '\n", + " f'{len(_not_placed)} 件 {_not_placed} / 別名の版 '\n", + " f'{_nv[\"version_count\"]} 件')" + ], + "id": "cell-040", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'b' and run_dotted and (state.get('dot') or {}).get('ok'):\n", + " # S-10b-10(任意): ドット入りバケット。失敗しても判定を落とさない。\n", + " _dot_b = {}\n", + " try:\n", + " async def _step(page):\n", + " await open_project_files(page, p_dot)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " _root = wb_root_url(addon_storage_id)\n", + " _dot_b['metadata'] = await collect_metadata(page, _root)\n", + " _dot_b['downloads'] = await collect_downloads(page, _root)\n", + "\n", + " await run_pw(_step)\n", + " _dot_b['ok'] = True\n", + " except Exception as _e:\n", + " _dot_b['ok'] = False\n", + " _dot_b['error'] = repr(_e)\n", + " print(f'[!] ドット入りバケットの一覧/DL が失敗した: {_e!r}')\n", + " keep_all('S-10b-dot-wb.json')\n", + " record_json('S-10b-dot.json', _dot_b, quiet=False)\n", + " print('S-10b-10 は参考扱い(判定には入れない)')\n", + "elif phase == 'b':\n", + " print('S-10b-10: 比較対象なし(phase a でドット入りバケットを作っていないか失敗した)')" + ], + "id": "cell-041", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'b':\n", + " # S-10b-11: 新規プロジェクトに**対象 PR 版で新規接続**し、1 件だけ置く。\n", + " # ここは「新実装で新しく接続したものがどう保存されるか」を見るためのもので、\n", + " # phase a が作った設定には触らない。\n", + " _newconn = {'project': p_newconn, 'bucket': s3_bucket_mig}\n", + " _nc_path = os.path.join(work_dir, f_newconn)\n", + " with open(_nc_path, 'w') as _f:\n", + " _f.write(f'S-10b newconn\\nrun_id: {run_id}\\n')\n", + "\n", + " async def _step(page):\n", + " await create_project_and_open_files(page, p_newconn)\n", + " await connect_s3_addon(page, p_newconn, s3_bucket_mig)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " await upload_and_wait(page, _nc_path)\n", + " # 保存された folder_id の形を画面から読む。アドオン設定ページの\n", + " # 選択中バケットの表示が `:/` の形かどうかを見る。\n", + " await page.locator('//a[text() = \"アドオン\"]').click()\n", + " await asyncio.sleep(3)\n", + " _scope = page.locator(f'//*[@id = \"{addon_storage_id}Scope\"]')\n", + " await expect(_scope).to_be_visible(timeout=transition_timeout)\n", + " _newconn['addon_page_text'] = (await _scope.inner_text())[:2000]\n", + " await page.screenshot(path=evidence('S-10b-newconn-addon.png'),\n", + " full_page=True)\n", + "\n", + " try:\n", + " await run_pw(_step)\n", + " _newconn['ok'] = True\n", + " except Exception as _e:\n", + " _newconn['ok'] = False\n", + " _newconn['error'] = repr(_e)\n", + " print(f'[!] 新規接続で例外: {_e!r}(記録して続行)')\n", + " keep_all('S-10b-newconn-wb.json')\n", + "\n", + " # S3 側に実体が届いたか。\n", + " try:\n", + " _h = s3_client(s3_region_default).head_object(\n", + " Bucket=s3_bucket_mig, Key=f_newconn)\n", + " _newconn['s3'] = {'ContentLength': _h['ContentLength'],\n", + " 'ETag': _h['ETag']}\n", + " except Exception as _e:\n", + " _newconn['s3'] = {'error': repr(_e)}\n", + " _newconn['sha256'] = sha256_file(_nc_path)\n", + " record_json('S-10b-newconn.json', _newconn, quiet=False)\n", + " state['newconn'] = _newconn\n", + " state['files'][f'newconn/{f_newconn}'] = {\n", + " 'where': 'newconn', 'name': f_newconn, 'placed_in_phase': 'b',\n", + " 'sha256': _newconn['sha256'], 'size': os.path.getsize(_nc_path)}\n", + " save_state(state)\n", + " b_record('S-10b-11', bool(_newconn.get('ok')),\n", + " f'新規接続 {\"成功\" if _newconn.get(\"ok\") else \"失敗\"} / '\n", + " f'S3 側 {_newconn.get(\"s3\")}')\n", + " print('`folder_id` の保存形は S-10b-newconn.json の addon_page_text と、'\n", + " '管理 API の node settings で確認すること')" + ], + "id": "cell-042", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'b':\n", + " # S-10b-9: 全段を通した WB 応答。\n", + " _bad = wb_failures(wb_all)\n", + " record_json('S-10b-wb-all.json', json.loads(_redact(\n", + " json.dumps(wb_all, ensure_ascii=False))))\n", + " b_record('S-10b-9', not _bad, f'4xx/5xx・署名系の文言 {len(_bad)} 件')\n", + " for _e in _bad[:20]:\n", + " print(f' - {_e[\"status\"]} {_e[\"method\"]} {_e[\"url\"]}')\n", + "\n", + " # まとめ。**ここで初めて assert する。**\n", + " _lines = ['| 番号 | 結果 | 内容 |', '|---|---|---|']\n", + " for _num in sorted(b_results):\n", + " _r = b_results[_num]\n", + " _lines.append(f'| {_num} | {\"PASS\" if _r[\"ok\"] else \"**FAIL**\"} | '\n", + " f'{_r[\"detail\"]} |')\n", + " _summary = '\\n'.join(_lines)\n", + " record('S-10b-summary.md',\n", + " f'# S-10b 判定(run_id {run_id})\\n\\n{_summary}\\n')\n", + " record('S-10b-verdict.txt', _summary)\n", + " save_state(state)\n", + " print()\n", + " print('=' * 74)\n", + " print(' phase b 完了。次にすること')\n", + " print('=' * 74)\n", + " print(' 1. 試験環境を **develop 版** に戻す')\n", + " print(' 2. このノートブックを phase=\"c\" で実行する')\n", + " print(f' state_path=\"{state_path}\" を必ず渡すこと')\n", + " print('=' * 74)\n", + " _failed = [n for n, r in b_results.items() if not r['ok']]\n", + " assert not _failed, f'S-10b で FAIL がある: {_failed}(S-10b-summary.md を見ること)'" + ], + "id": "cell-043", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## phase c: ロールバック(**試験環境は develop 版に戻してある**)\n", + "\n", + "phase b が対象 PR 版で置いた新データを、旧実装で読めるかを見る。\n", + "終わったら後始末までこの段で行う。\n", + "\n", + "| 番号 | 見るもの | 期待 |\n", + "|---|---|---|\n", + "| S-10c-1 | `S-10b-new-*` の一覧・ダウンロード・別名 3 版の削除 | 成功 |\n", + "| S-10c-2 | S-10b-11 で**新規接続した**プロジェクトを開く | **失敗する見込み。何が起きるかを記録するのが目的** |\n", + "\n", + "### S-10c-2 を「失敗してもよい」扱いにする理由\n", + "\n", + "対象 PR 版はバケットのルートを選んでも接続先を `<バケット名>:/` の形で保存する。\n", + "develop 版はこの文字列をそのままバケット名として使うため、ロールバック後に\n", + "`<バケット名>:/` という名前のバケットを探しにいく。**この段では例外で止めず、\n", + "WB の応答と画面の表示を記録する**。記録は「ロールバックするときは新実装で\n", + "接続したアドオンの再接続が要る」という運用手順の根拠にする。" + ], + "id": "cell-044" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'c':\n", + " c_results = {}\n", + "\n", + " def c_record(num, ok, detail):\n", + " c_results[num] = {'ok': bool(ok), 'detail': detail}\n", + " print(f'{num}: {\"PASS\" if ok else \"FAIL\"} — {detail}')\n", + " return ok\n", + "\n", + " # S-10c-1: phase b が置いた新データを develop 版で読む。\n", + " c_meta, c_down = {}, {}\n", + "\n", + " async def _step(page):\n", + " await open_project_files(page, p_addon)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " _root = wb_root_url(addon_storage_id)\n", + " c_meta['addon'] = await collect_metadata(page, _root)\n", + " c_down['addon'] = await collect_downloads(page, _root)\n", + "\n", + " await run_pw(_step)\n", + " keep_all('S-10c-fetch.json')\n", + " record_json('S-10c-metadata.json', c_meta)\n", + " record_json('S-10c-downloads.json', c_down)\n", + "\n", + " _expected = {k: v for k, v in state.get('files', {}).items()\n", + " if v.get('placed_in_phase') == 'b' and v['where'] == 'addon'}\n", + " # collect_downloads のキーは WB の materialized パス(例 `/S-10b-new-日本語.txt`)。\n", + " # 控えはファイル名で持っているので、basename で引き当てる。\n", + " _by_name = {os.path.basename(k.rstrip('/')): v\n", + " for k, v in (c_down.get('addon') or {}).items()}\n", + " _mismatch = []\n", + " for _key, _want in _expected.items():\n", + " _name = _want['name']\n", + " _got = _by_name.get(_name)\n", + " if _got is None:\n", + " _mismatch.append({'name': _name, 'problem': 'not-downloadable',\n", + " 'available': sorted(_by_name)})\n", + " elif _got['sha256'] != _want['sha256']:\n", + " _mismatch.append({'name': _name, 'expected': _want['sha256'],\n", + " 'actual': _got['sha256']})\n", + " record_json('S-10c-downloads-diff.json', _mismatch, quiet=False)\n", + " c_record('S-10c-1-downloads', not _mismatch,\n", + " f'新データ {len(_expected)} 件中 不一致 {len(_mismatch)} 件')" + ], + "id": "cell-045", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'c':\n", + " # S-10c-1(続き): phase b が作った別名 3 版を develop 版の UI から削除する。\n", + " _before = list_versions(s3_bucket_migver, f_new_ver)\n", + " record_json('S-10c-versions-before.json', _before, quiet=False)\n", + "\n", + " async def _step(page):\n", + " await open_project_files(page, p_ver)\n", + " wb_responses.clear()\n", + " await open_storage(page, addon_storage_name)\n", + " await delete_file_from_toolbar(page, f_new_ver)\n", + " await asyncio.sleep(5)\n", + "\n", + " try:\n", + " await run_pw(_step)\n", + " _err = None\n", + " except Exception as _e:\n", + " _err = repr(_e)\n", + " print(f'[!] 削除操作で例外: {_e!r}(記録して続行)')\n", + " keep_all('S-10c-versions-wb.json')\n", + "\n", + " _after = list_versions(s3_bucket_migver, f_new_ver)\n", + " record_json('S-10c-versions-after.json', _after, quiet=False)\n", + " c_record('S-10c-1-versions',\n", + " _after['version_count'] == 0 and _after['delete_marker_count'] == 0,\n", + " f'削除前 版 {_before[\"version_count\"]} / 削除後 版 '\n", + " f'{_after[\"version_count\"]} + 削除マーカー '\n", + " f'{_after[\"delete_marker_count\"]}'\n", + " + (f' / 例外 {_err}' if _err else ''))" + ], + "id": "cell-046", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'c':\n", + " # S-10c-2: 対象 PR 版で新規接続したプロジェクトを develop 版で開く。\n", + " # **失敗が期待値。例外で止めず、何が起きたかを残す。**\n", + " _c2 = {'project': p_newconn,\n", + " 'note': ('対象 PR 版は接続先を `<バケット名>:/` の形で保存する。'\n", + " 'develop 版はこれをそのままバケット名として使うので、'\n", + " 'ここは失敗する見込み。失敗の仕方を記録するのが目的。')}\n", + "\n", + " async def _step(page):\n", + " await open_project_files(page, p_newconn)\n", + " wb_responses.clear()\n", + " try:\n", + " await open_storage(page, addon_storage_name)\n", + " _c2['opened'] = True\n", + " except Exception as e:\n", + " _c2['opened'] = False\n", + " _c2['open_error'] = repr(e)\n", + " await asyncio.sleep(5)\n", + " _c2['growls'] = await growl_messages(page)\n", + " _c2['tree_text'] = (await page.locator(\n", + " '//*[@id = \"treeGrid\"]').inner_text())[:2000]\n", + " await page.screenshot(path=evidence('S-10c-newconn.png'),\n", + " full_page=True)\n", + "\n", + " try:\n", + " await run_pw(_step)\n", + " except Exception as _e:\n", + " _c2['error'] = repr(_e)\n", + " print(f'[!] S-10c-2 で例外: {_e!r}(これ自体が記録対象。続行する)')\n", + "\n", + " _c2['wb'] = [{'status': e['status'], 'method': e['method'],\n", + " 'url': e['url'], 'body': (e.get('body') or '')[:1000]}\n", + " for e in wb_responses]\n", + " _c2['wb_failures'] = [{'status': e['status'], 'url': e['url']}\n", + " for e in wb_failures()]\n", + " keep_all('S-10c-newconn-wb.json')\n", + " record_json('S-10c-newconn.json', json.loads(_redact(\n", + " json.dumps(_c2, ensure_ascii=False))), quiet=False)\n", + " # **判定には入れない。** 事実の記録だけ。\n", + " print()\n", + " print('S-10c-2 は判定項目ではない(失敗が期待値)。'\n", + " 'S-10c-newconn.json の内容を運用手順の根拠にすること')" + ], + "id": "cell-047", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'c':\n", + " # S-10c のまとめ。\n", + " _bad = wb_failures(wb_all)\n", + " _lines = ['| 番号 | 結果 | 内容 |', '|---|---|---|']\n", + " for _num in sorted(c_results):\n", + " _r = c_results[_num]\n", + " _lines.append(f'| {_num} | {\"PASS\" if _r[\"ok\"] else \"**FAIL**\"} | '\n", + " f'{_r[\"detail\"]} |')\n", + " _lines.append(f'| S-10c-2 | 記録のみ | 判定には入れない'\n", + " f'(S-10c-newconn.json)|')\n", + " record('S-10c-summary.md',\n", + " f'# S-10c 判定(run_id {run_id})\\n\\n' + '\\n'.join(_lines)\n", + " + f'\\n\\nWB 応答の 4xx/5xx・署名系の文言(S-10c-2 を含む全段): '\n", + " f'{len(_bad)} 件\\n')\n", + " record('S-10c-verdict.txt', '\\n'.join(_lines))\n", + " record_json('S-10c-wb-all.json', json.loads(_redact(\n", + " json.dumps(wb_all, ensure_ascii=False))))" + ], + "id": "cell-048", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'c':\n", + " # 後始末: 生成バケットを中身ごと削除する。**ここでだけ True にする。**\n", + " delete_generated_buckets = True\n", + " _text = cleanup_buckets(verbose=True)\n", + " record('S-10c-cleanup.txt',\n", + " '--- 生成バケットの削除 ---\\n' + _text + '\\n')\n", + " _all = [b['Name'] for b in\n", + " s3_client(s3_region_default).list_buckets().get('Buckets', [])]\n", + " _made = [n for n, r in generated_bucket_names]\n", + " _left = [b for b in _made if b in _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", + " assert not _left, (\n", + " f'生成したバケットが残っている: {_left}。'\n", + " 'S-10c-cleanup.txt を見て手で削除すること')" + ], + "id": "cell-049", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 後始末チェックリスト(phase c)\n", + "\n", + "**この試験は機関ストレージの設定を 1 回上書きしている。**\n", + "`S-10a-institutional-storage-before.txt` に登録前の状態が残っているので、\n", + "それを見ながら次を消化すること。\n", + "\n", + "- [ ] 機関ストレージを `S-10a-institutional-storage-before.txt` の状態に戻す\n", + " (**手動**。管理画面で元のプロバイダを選び直して保存する。\n", + " 「登録を外す」操作は無い)\n", + "- [ ] 本ノートブックが作ったプロジェクトを削除する(次のセルが名前を出す)\n", + "- [ ] ユーザー設定 →「アドオンアカウント構成」から S3 アカウントの連携を解除する\n", + " (アドオンのアカウントは**ユーザー単位**で残る)\n", + "- [ ] 生成したバケットが消えていることを確認する(上のセル)\n", + "- [ ] `S-10-state.json` を証跡としてまとめて保管する" + ], + "id": "cell-050" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "if phase == 'c':\n", + " # 削除対象のプロジェクト(削除は手動で行う)。\n", + " print('削除対象のプロジェクト:')\n", + " for _p in [p_inst, p_addon, p_ver, p_dot, p_newconn]:\n", + " if _p:\n", + " print(' -', _p)\n", + " print()\n", + " print('RDM-e2e-test-nb の scripts/cleanup_sigv4_run.py が'\n", + " 'プロジェクト削除とアドオン連携解除を行う:')\n", + " print(f' python3 scripts/cleanup_sigv4_run.py --prefix {rdm_project_prefix}'\n", + " ' --dry-run')\n", + " print(' (--prefix は本 NB のプロジェクト名の接頭辞。接尾辞の組が違うので、'\n", + " '対象が出ないときは手で削除すること)')" + ], + "id": "cell-051", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 証跡の INDEX。\n", + "_scenario = {\n", + " 'S-10a': '仕込み(develop 版)',\n", + " 'S-10b': '検証(対象 PR 版)',\n", + " 'S-10c': 'ロールバック(develop 版)',\n", + " 'S-10-': '段をまたぐ控え',\n", + "}\n", + "\n", + "\n", + "def write_index():\n", + " \"\"\"run_dir の中身から INDEX.md を作る。\"\"\"\n", + " _index = ['# 証跡 INDEX (S-10 移行互換)',\n", + " '',\n", + " f'- phase: {phase}',\n", + " f'- run_id: {run_id}',\n", + " f'- 取得日時: {datetime.now().isoformat()}',\n", + " f'- 対象: RCOSDP/RDM-waterbutler#93 / RCOSDP/RDM-osf.io#746',\n", + " f'- 控え: {state_path}',\n", + " '',\n", + " '| ファイル | サイズ | 段 |',\n", + " '|---|---|---|']\n", + " for _f in sorted(os.listdir(run_dir)):\n", + " if _f == 'INDEX.md' or _f.startswith(('har', 'video-', 'last-')) \\\n", + " or _f == 'console.log':\n", + " continue\n", + " _label = next((v for k, v in _scenario.items() if _f.startswith(k)),\n", + " 'Playwright 成果物')\n", + " _index.append(f'| `{_f}` | '\n", + " f'{os.path.getsize(os.path.join(run_dir, _f))} | '\n", + " f'{_label} |')\n", + " _index.append('')\n", + " _index.append('`har.zip` / `video-*.webm` / `console.log` / '\n", + " '`last-*.{png,html}` は `scripts/playwright.py` が '\n", + " '`finish_pw_context` で書き出したもの。')\n", + " record('INDEX.md', '\\n'.join(_index), quiet=True)\n", + "\n", + "\n", + "write_index()" + ], + "id": "cell-052", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [68] の複製。\n", + "# Playwright を閉じる。**これをしないと HAR が書き出されない。**\n", + "# 既に har.zip があるなら退避する(finish_pw_context は同じ場所に上書きする)。\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)" + ], + "id": "cell-053", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 回帰 NB セル [70] の複製。HAR から presigned への 302 を数える。\n", + "# **phase b 以外では参考値**(302 が 0 でも落とさない)。対象 PR 版で\n", + "# 302 が出ないことだけが欠陥の兆候で、develop 版の段はその判定の場ではない。\n", + "import zipfile\n", + "\n", + "_har_zip = os.path.join(run_dir, 'har.zip')\n", + "har_verdict = {'phase': phase, 'har_zip': _har_zip,\n", + " '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' or '/v1/resources/' not in _req['url']:\n", + " continue\n", + " if not re.search(r'/providers/(osfstorage|s3)/', _req['url']):\n", + " continue\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", + " if _h['name'].lower() == 'location':\n", + " _loc = _h['value']\n", + " _redirects.append(redirect_entry(_req['url'], _res['status'], _loc))\n", + "\n", + " _ok = [r for r in _redirects + g10_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['live_redirects'] = g10_redirects\n", + " har_verdict['presigned_redirects'] = len(_ok)\n", + " record(f'S-10{phase}-download-redirect.json',\n", + " _redact(json.dumps(har_verdict, indent=2, ensure_ascii=False)))\n", + " print(f'presigned URL への 302: {len(_ok)} 件')\n", + " if phase == 'b' and not _ok:\n", + " # 「経路を踏んでいない」と「踏んだのに 302 にならない」は結論が全く違う。\n", + " # 取り違えると欠陥を捏造することになるので言い分ける。\n", + " _nd = [r for r in _redirects + g10_redirects if not r.get('has_direct')]\n", + " print('[!] phase b で presigned への 302 が 1 件も無い。'\n", + " + ('ダウンロード要求がすべて `direct` 付きで、その経路を'\n", + " '踏んでいない。一覧のツールバーの[ダウンロード]で'\n", + " '取り直すこと'\n", + " if not _nd else\n", + " '`direct` の無いダウンロード要求はあるのに 302 に'\n", + " 'なっていない')\n", + " + f'。S-10{phase}-download-redirect.json を見ること')\n", + "else:\n", + " record_json(f'S-10{phase}-download-redirect.json', har_verdict, quiet=False)\n", + " print(f'[!] {_har_zip} が無い。default_result_path が run_dir を指していないか、'\n", + " 'finish_pw_context が走っていない')\n", + "\n", + "write_index()" + ], + "id": "cell-054", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "print('証跡:', run_dir)\n", + "print('控え:', state_path)\n", + "print('作業ディレクトリを削除します:', work_dir)" + ], + "id": "cell-055", + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "!rm -fr {work_dir}" + ], + "id": "cell-056", + "outputs": [], + "execution_count": null + } + ], + "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 aa4ce7ce0239793ec613d3edd9171e640c789af8 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Wed, 14 Oct 2026 15:23:16 +0900 Subject: [PATCH 21/26] test(s3): clean up supplied buckets too at the end of the migration run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 後始末が生成バケットの delete_bucket しか行っておらず、パラメータで既存の バケットを指定した場合に本 NB が置いたオブジェクトが残ったままになっていた。 回帰 NB のセル [71] と同じく、指定バケットでは本 NB が置いたキーだけを 全バージョン消し、バケット自体は残すようにする。 消す対象は控え(S-10-state.json)に記録したキーと、本 NB が作ったフォルダの 接頭辞だけに限る。バージョニング有効バケットでは削除マーカーも残さない。 --- ...350\241\214\344\272\222\346\217\233.ipynb" | 55 ++++++++++++++++++- 1 file changed, 53 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" index bab49b4..53bb792 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" @@ -3119,11 +3119,62 @@ "metadata": {}, "source": [ "if phase == 'c':\n", - " # 後始末: 生成バケットを中身ごと削除する。**ここでだけ True にする。**\n", + " # 後始末(回帰 NB セル [71] の複製)。\n", + " # - パラメータで**指定された**バケット(ユーザー所有)は、本 NB が置いた\n", + " # 残骸だけを消す。バケット自体は消さない。\n", + " # - phase a が**生成した**バケットは中身ごと delete_bucket する。\n", + " _generated_names = {n for n, r in generated_bucket_names}\n", + " _leftovers = []\n", + "\n", + "\n", + " def _delete_keys(bucket, keys, region):\n", + " \"\"\"指定したキーを全バージョン消す(バージョニング有効でも残さない)。\"\"\"\n", + " c = s3_client(region)\n", + " deleted = 0\n", + " for key in keys:\n", + " while True:\n", + " r = c.list_object_versions(Bucket=bucket, Prefix=key)\n", + " objs = [{'Key': v['Key'], 'VersionId': v['VersionId']}\n", + " for v in r.get('Versions', []) + r.get('DeleteMarkers', [])\n", + " if v['Key'] == key or v['Key'].startswith(key)]\n", + " if not objs:\n", + " break\n", + " for i in range(0, len(objs), 1000):\n", + " c.delete_objects(Bucket=bucket,\n", + " Delete={'Objects': objs[i:i + 1000]})\n", + " deleted += len(objs)\n", + " return deleted\n", + "\n", + "\n", + " # 本 NB が置いたキー。S3 上のキーは materialized パスの先頭の / を外したもの。\n", + " _placed = sorted({\n", + " (v.get('key') or ((v.get('folder') + '/') if v.get('folder') else '')\n", + " + v['name'])\n", + " for v in state.get('files', {}).values()})\n", + " _placed += [f'{folder_top}/', 'S-10-raw/']\n", + " for _role, _bucket in (('mig', s3_bucket_mig), ('migver', s3_bucket_migver),\n", + " ('dot', s3_bucket_dot)):\n", + " if not _bucket:\n", + " continue\n", + " if _bucket in _generated_names:\n", + " _leftovers.append(f' {_bucket}: 生成バケットなので残骸削除は省略'\n", + " '(このあと delete_bucket する)')\n", + " continue\n", + " _n = _delete_keys(_bucket, _placed, s3_region_default)\n", + " _left = s3_client(s3_region_default).list_objects_v2(\n", + " Bucket=_bucket, MaxKeys=20)\n", + " _leftovers.append(\n", + " f' {_bucket}(指定バケット): {_n} 件削除 / 残り KeyCount='\n", + " f'{_left.get(\"KeyCount\", 0)} '\n", + " f'{[o[\"Key\"] for o in _left.get(\"Contents\", [])]}')\n", + "\n", + " # **ここでだけ True にする。**\n", " delete_generated_buckets = True\n", " _text = cleanup_buckets(verbose=True)\n", " record('S-10c-cleanup.txt',\n", - " '--- 生成バケットの削除 ---\\n' + _text + '\\n')\n", + " '--- 指定バケット(ユーザー所有)の残骸削除 ---\\n'\n", + " + ('\\n'.join(_leftovers) or ' 対象なし')\n", + " + '\\n\\n--- 生成バケットの削除 ---\\n' + _text + '\\n')\n", " _all = [b['Name'] for b in\n", " s3_client(s3_region_default).list_buckets().get('Buckets', [])]\n", " _made = [n for n, r in generated_bucket_names]\n", From 8366e9b7cb541082eed16cebdd1d6c8f2f42d2d6 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Fri, 16 Oct 2026 13:11:00 +0900 Subject: [PATCH 22/26] test(s3): find the institutional storage node by icon, not by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ファイルツリーのノードのアイコンは osf.io 側がストレージ名から決めており、 名前が 'NII Storage' / 'United States' のときだけ通常アイコンのままになる。 custom のアイコンだけを目印にしていたため、その名前で登録された機関ストレージ のノードを見つけられなかった。 /static/addons/osfstorage/comicon で始まる背景画像を目印にして、通常・custom の どちらでも拾うようにする。候補が複数あればパラメータの名前と一致するものを 優先し、一致が無ければ先頭を採って警告を出す。見つからないときのメッセージに 名前とアイコンのルールを書いた。 あわせて既定のストレージ名を既定ストレージと区別しやすいものに変え、名前の 入力欄が無い・無効・既に値が入っている場合に、既存名のまま進めることを出力で 明示するようにした。 --- ...350\241\214\344\272\222\346\217\233.ipynb" | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" index 53bb792..4b94d80 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" @@ -73,7 +73,9 @@ "s3_server_side_encryption = False\n", "\n", "# 機関ストレージの表示名(ファイルツリーに表示される名前)\n", - "institutional_storage_name = 'NII Storage'\n", + "# 'NII Storage' / 'United States' は osf.io 側でファイルツリーのアイコンが\n", + "# 通常アイコンのままになり、既定ストレージと見分けが付きにくいので使わない。\n", + "institutional_storage_name = 'S3 E2E Migration'\n", "\n", "# S3 アドオン(外部ストレージ)の表示名と ID\n", "addon_storage_name = 'Amazon S3'\n", @@ -921,18 +923,54 @@ " await expect(upload_btn).to_be_visible(timeout=transition_timeout)\n", "\n", "\n", + "# ファイルツリーのノードのアイコンは osf.io の\n", + "# website/project/views/file.py の grid_data._inner が決めている。機関ストレージ\n", + "# (provider が osfstorage で nodeRegion を持つノード)は、ストレージ名が\n", + "# 'NII Storage' / 'United States' のときだけ通常アイコン\n", + "# (/static/addons/osfstorage/comicon.png)のままで、それ以外の名前のときに\n", + "# comicon_custom_storage.png に差し替わる。つまりアイコンの種類はストレージ名\n", + "# 次第なので、どちらでも拾えるように「/static/addons/osfstorage/comicon」で\n", + "# 始まる背景画像を目印にする。\n", + "INSTITUTIONAL_STORAGE_ICON = '/static/addons/osfstorage/comicon'\n", + "\n", + "INSTITUTIONAL_STORAGE_TITLE_XPATH = (\n", + " '//*[contains(@class, \"tb-td-first\")]'\n", + " f'//*[contains(@style, \"{INSTITUTIONAL_STORAGE_ICON}\")]'\n", + " '/../../following-sibling::*[contains(@class, \"title-text\")]')\n", + "\n", + "NO_INSTITUTIONAL_STORAGE_NODE = (\n", + " 'ファイルツリーに機関ストレージのノードが見つからない。ノードのアイコンは'\n", + " 'osf.io 側がストレージ名から決めており、名前が \"NII Storage\" / '\n", + " '\"United States\" のときは通常アイコン'\n", + " '(/static/addons/osfstorage/comicon.png)、それ以外の名前のときは'\n", + " '/static/addons/osfstorage/comicon_custom_storage.png になる。ここは'\n", + " '両方を見ているので、見つからないのはアイコンの違いではなく、この'\n", + " 'プロジェクトに機関ストレージが付いていない可能性が高い。')\n", + "\n", + "\n", "async def resolve_institutional_storage_name(page):\n", " \"\"\"ファイルツリーの機関ストレージノードの表示名を読む。\n", "\n", " 表示名は管理画面の #storage_name の値で決まるので環境ごとに違う。\n", - " ノードのアイコン(comicon_custom_storage.png)で特定して実名を読む。\n", + " アイコンは通常・custom のどちらもありうるので両方を拾い、候補が複数\n", + " あればパラメータ institutional_storage_name と一致するものを優先する。\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", + " titles = page.locator(INSTITUTIONAL_STORAGE_TITLE_XPATH)\n", + " try:\n", + " await expect(titles.first).to_be_visible(timeout=transition_timeout)\n", + " except AssertionError:\n", + " raise AssertionError(NO_INSTITUTIONAL_STORAGE_NODE)\n", + " names = [n.strip() for n in await titles.all_inner_texts() if n.strip()]\n", + " if not names:\n", + " raise AssertionError(NO_INSTITUTIONAL_STORAGE_NODE)\n", + " if len(names) > 1:\n", + " print(f'[!] 機関ストレージ候補のノードが {len(names)} 件ある: {names}')\n", + " if institutional_storage_name in names:\n", + " print(' -> パラメータと一致する '\n", + " f'{institutional_storage_name!r} を使う')\n", + " return institutional_storage_name\n", + " print(f' -> 一致するものが無いので先頭の {names[0]!r} を使う')\n", + " return names[0]\n", "\n", "\n", "async def open_institutional_storage(page):\n", @@ -1206,6 +1244,14 @@ " f'value=\"{sn_value}\"')\n", " if sn_visible and sn_enabled and not sn_value.strip():\n", " await storage_name.fill(institutional_storage_name)\n", + " else:\n", + " why = ('入力欄が出ていない' if not sn_visible\n", + " else '入力欄が無効' if not sn_enabled\n", + " else '既に名前が入っている')\n", + " print(f' ストレージ名は入力しない({why})。'\n", + " f'既存の名前 \"{sn_value}\" のまま進める。'\n", + " 'ノードはファイルツリーのアイコンで特定するので、'\n", + " '名前がパラメータと違っても見つかる。')\n", "\n", " save_btn = page.locator(\n", " '//button[@type=\"submit\" and contains(@class, \"btn-success\")]').first\n", From 54c0d230912748136a2b1c4b2844d8f84bd92b1b Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Fri, 16 Oct 2026 15:43:14 +0900 Subject: [PATCH 23/26] test(s3): wait for the addon settings pane before deciding how to connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit アドオンを有効にした直後は、構成の中身が knockout の if: / visible: バインド の中にあるため、「アカウントに接続する」「プロフィールからアカウントを インポート」「バケットの選択」のどれも出ていない瞬間がある。そこを一度だけ count() / is_visible() で見て分岐していたので、描画が間に合わないと 「入り口が無い」と判定して落ちていた。 3 つのうちどれかが可視になるまで expect().to_be_visible() で待ってから 分岐する。固定の sleep は保険として残すが、待ちの根拠にはしない。 それでも入り口が見つからないときの例外に #Scope のテキスト (先頭 500 文字)を添え、何が描画されていたのかが証跡だけで分かるようにした。 --- ...350\241\214\344\272\222\346\217\233.ipynb" | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" index 4b94d80..44d2bae 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" @@ -1469,6 +1469,26 @@ " await connect_btn.click(timeout=transition_timeout)\n", "\n", "\n", + "async def no_addon_entry_point(page):\n", + " \"\"\"アドオン構成に入り口が無いときの AssertionError を作る。\n", + "\n", + " 何が描かれていたのかが証跡だけで分かるように #Scope のテキストを\n", + " 添える。例外は返すだけで、送出は呼び出し側に任せる。\n", + " \"\"\"\n", + " scope = page.locator(f'//*[@id = \"{addon_storage_id}Scope\"]')\n", + " if await scope.count() == 0:\n", + " shown = f'#{addon_storage_id}Scope が描画されていない'\n", + " else:\n", + " text = (await scope.first.inner_text()).strip()\n", + " if not text:\n", + " text = ' '.join((await scope.first.text_content() or '').split())\n", + " shown = text[:500] or '(テキストが無い)'\n", + " return AssertionError(\n", + " 'アドオン構成に[アカウントに接続する]も[プロフィールから'\n", + " 'アカウントをインポート]も無く、バケットの選択も出ていない。'\n", + " f'#{addon_storage_id}Scope の表示(先頭 500 文字): {shown}')\n", + "\n", + "\n", "async def connect_s3_addon(page, project_name, bucket):\n", " \"\"\"プロジェクトに S3 アドオンを接続し、ファイルページを開く。\n", "\n", @@ -1519,6 +1539,19 @@ " connect_btn = page.locator('//button[text() = \"接続\"]').last\n", " new_bucket = page.locator('//button[@id = \"newBucket\"]')\n", "\n", + " # 「アドオンを構成」の中身は knockout が描く。[有効にする]の直後は\n", + " # mako がサーバ側で描画した HTML だけが見えていたり、applyBindings 直後で\n", + " # if: バインドの中身が消えたままだったりする(設定の取得が返るまで\n", + " # showTokenCreateButton も showImport も false、#newBucket は\n", + " # visible: バインドで隠れている)。この瞬間に count() / is_visible() を\n", + " # 一度だけ見ると「どれも無い」と判定して落ちる。上の sleep は保険で、\n", + " # 待ちの根拠にはしない。3 つのうちどれかが見えるまで待ってから分岐する。\n", + " entry_point = connect_link.or_(import_link).or_(new_bucket).first\n", + " try:\n", + " await expect(entry_point).to_be_visible(timeout=transition_timeout)\n", + " except AssertionError:\n", + " raise await no_addon_entry_point(page)\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", @@ -1542,9 +1575,7 @@ " '//button[.//span[text() = \"変更\" or text() = \"Change\"]]').click(\n", " timeout=transition_timeout)\n", " else:\n", - " raise AssertionError(\n", - " 'アドオン構成に[アカウントに接続する]も[プロフィールから'\n", - " 'アカウントをインポート]も無く、バケットの選択も出ていない。')\n", + " raise await no_addon_entry_point(page)\n", "\n", " await expect(page.locator('//button[@id = \"newBucket\"]')).to_be_visible(\n", " timeout=transition_timeout)\n", From 167402339e3c9ca69ab9902c0c8ca658254992c5 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Fri, 16 Oct 2026 21:30:22 +0900 Subject: [PATCH 24/26] test(s3): collect metadata by recorded paths, not by the last WB response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wb_root_url() は応答レコーダから WB のルート URL を切り出していたが、 レコーダは page.on('response') なのでノートブックが page.request.get で 取った分は入らず、ファイルツリーの一覧も osf.io の grid API を通るので WB の応答が 1 件も出ないことがある。空だと wb_all(全段ぶんの控え)に 落ちて、直前に開いていた別プロジェクトのルートを返していた。その結果 addon も ver もドット入りバケットのプロジェクトを見てしまい、1 件しか 採れていなかった。 ルートは osf.io が contextVars に出している waterbutlerURL と node.id から組み立てる(wb_provider_root)。採取は控え state['files'] のパスを 1 件ずつ、親フォルダの一覧と単体の ?meta= の両方で確かめる形にした。 フォルダは一覧の子リンクを辿るので osfstorage でも同じように動く。 引けなかったものは error として残し、突合で出す。 phase a の基準値として S3 を直接 head_object するセルを足した。develop 版の WaterButler は md5 = ETag.replace('"', '') を返すので、引用符を 外した ETag が md5 の期待値になる。phase b の突合は phase a の実測が あればそれを、無ければこの ETag を期待値にし、どちらを使ったかを basis に書く。 --- ...350\241\214\344\272\222\346\217\233.ipynb" | 465 +++++++++++++++--- 1 file changed, 383 insertions(+), 82 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" index 44d2bae..a7d8fc0 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" @@ -1714,24 +1714,30 @@ " return isinstance(value, str) and '\"' in value\n", "\n", "\n", - "def wb_root_url(provider, entries=None):\n", - " \"\"\"直近の WB 応答から、今開いているプロジェクトの当該プロバイダのルート URL を作る。\n", - "\n", - " `https:///v1/resources//providers//` までを切り出す。\n", - " guid はプロジェクトごとに違うので、**対象のプロジェクトを開いた直後に**\n", - " 呼ぶこと(開く前に wb_responses を空にしておくと確実)。\n", + "async def wb_provider_root(page, provider):\n", + " \"\"\"今開いているプロジェクトの、その WB プロバイダのルート URL を作る。\n", + "\n", + " `https:///v1/resources//providers//` を組み立てる。\n", + "\n", + " **応答レコーダ(wb_responses / wb_all)には頼らない。** レコーダは\n", + " page.on('response') なのでブラウザが出した要求しか拾わず、\n", + " page.request.get の分は入らない。ファイルツリーの一覧も osf.io 側の\n", + " grid API を通るので、プロジェクトを開いても WB の応答が 1 件も\n", + " 出ないことがある。空のまま wb_all に落ちると **前に開いた別の\n", + " プロジェクト**のルートを掴む(run-20261016-205046 の事象)。\n", + " osf.io は全ページの contextVars に WB の URL とノードの GUID を\n", + " 出しているので、そこから読めば取り違えない。\n", " \"\"\"\n", - " import re as _re\n", - " entries = (wb_responses or wb_all) if entries is None else entries\n", - " pat = _re.compile(r'^(https?://[^/]+/v1/resources/[^/]+/providers/'\n", - " + _re.escape(provider) + r'/)')\n", - " for e in reversed(entries):\n", - " m = pat.match(e['url'])\n", - " if m:\n", - " return m.group(1)\n", - " raise AssertionError(\n", - " f'WB の {provider} のルート URL が応答から見つからない。'\n", - " 'プロジェクトのファイルタブを開いてから呼ぶこと')\n", + " ctx = await page.evaluate(\n", + " '() => { const c = window.contextVars || {};'\n", + " ' return {wb: c.waterbutlerURL || null,'\n", + " ' guid: (c.node && c.node.id) || null}; }')\n", + " wb, guid = ctx.get('wb'), ctx.get('guid')\n", + " assert wb and guid, (\n", + " 'contextVars から WB の URL とプロジェクトの GUID が読めない。'\n", + " 'プロジェクトのページを開いてから呼ぶこと。'\n", + " f' 現在地: {page.url} / 読めた値: {ctx}')\n", + " return f'{wb.rstrip(\"/\")}/v1/resources/{guid}/providers/{provider}/'\n", "\n", "\n", "async def wb_get_json(page, url):\n", @@ -1777,6 +1783,72 @@ " return items, pages\n", "\n", "\n", + "async def fetch_listing_by_name(page, folder_url):\n", + " \"\"\"フォルダの一覧(?meta=)を {名前: (attributes, links)} で返す。\"\"\"\n", + " items, _ = await fetch_listing(page, folder_url)\n", + " return {attrs.get('name'): (attrs, links)\n", + " for attrs, links in items.values()}\n", + "\n", + "\n", + "async def resolve_folder(page, root, folders, cache):\n", + " \"\"\"ルートから folders を 1 段ずつ辿り、(そのフォルダの URL, 一覧) を返す。\n", + "\n", + " **osfstorage はパスがファイル ID なので、名前から URL を組み立てられない。**\n", + " 一覧に出てきた子の link を辿る形にすれば s3 でも osfstorage でも同じに\n", + " なる。cache は一覧の取り直しを避けるための {たどった道: 一覧}。\n", + " \"\"\"\n", + " url, key = root, ''\n", + " if key not in cache:\n", + " cache[key] = await fetch_listing_by_name(page, url)\n", + " listing = cache[key]\n", + " for name in folders:\n", + " attrs, links = listing.get(name, (None, None))\n", + " assert attrs is not None, (\n", + " f'フォルダ \"{name}\" が一覧に無い({url})。'\n", + " f' 一覧にあるもの: {sorted(listing)}')\n", + " assert attrs.get('kind') == 'folder', f'\"{name}\" はフォルダではない'\n", + " sub = links.get('upload') or links.get('new_folder')\n", + " assert sub, f'フォルダ \"{name}\" の子の URL が一覧に無い'\n", + " url = sub.split('?')[0]\n", + " key = f'{key}/{name}'\n", + " if key not in cache:\n", + " cache[key] = await fetch_listing_by_name(page, url)\n", + " listing = cache[key]\n", + " return url, listing\n", + "\n", + "\n", + "async def resolve_file(page, root, rel, cache):\n", + " \"\"\"控えの相対パスから (attributes, links, 一覧の URL) を引く。\n", + "\n", + " 見つからないときは (None, None, 理由) を返す。例外にしないのは、\n", + " 1 件の欠落で残りの採取を止めないため。\n", + " \"\"\"\n", + " segments = [s for s in rel.split('/') if s]\n", + " *folders, name = segments\n", + " try:\n", + " folder_url, listing = await resolve_folder(page, root, folders, cache)\n", + " except AssertionError as e:\n", + " return None, None, str(e)\n", + " attrs, links = listing.get(name, (None, None))\n", + " if attrs is None:\n", + " return None, None, (f'一覧に無い: \"{name}\"'\n", + " f'({meta_url_of(folder_url)})。'\n", + " f' 一覧にあるもの: {sorted(listing)}')\n", + " return attrs, links, meta_url_of(folder_url)\n", + "\n", + "\n", + "def scope_rel_paths(scope, files=None):\n", + " \"\"\"控え state['files'] から、その段に置いたファイルの相対パスを取る。\n", + "\n", + " 控えのキーは `<段>/<相対パス>`(例 `addon/S-10-folder/サブ/nested.txt`)。\n", + " **「一覧に見えたもの」ではなく「置いたもの」を起点にする**ので、\n", + " 採り漏らしが黙って消えない。\n", + " \"\"\"\n", + " files = state.get('files', {}) if files is None else files\n", + " return [key.split('/', 1)[1] for key, info in sorted(files.items())\n", + " if info.get('where') == scope and '/' in key]\n", + "\n", + "\n", "async def collect_metadata(page, folder_url, recurse=True, _depth=0):\n", " \"\"\"フォルダ以下の全ファイルの「一覧側」と「単体取得側」のメタデータを集める。\n", "\n", @@ -1807,44 +1879,145 @@ " return out\n", "\n", "\n", - "def metadata_diff(before, after):\n", + "async def collect_metadata_paths(page, root, rel_paths):\n", + " \"\"\"控えのパスぶんだけ、一覧側と単体取得側のメタデータを採る。\n", + "\n", + " 戻り値: {'/<相対パス>': {'listing': ..., 'headers': ...}}。\n", + " 引けなかったものは {'error': 理由} を残す(黙って欠かさない)。\n", + " \"\"\"\n", + " out, cache = {}, {}\n", + " for rel in rel_paths:\n", + " attrs, links, where = await resolve_file(page, root, rel, cache)\n", + " key = '/' + rel\n", + " if attrs is None:\n", + " out[key] = {'error': where}\n", + " continue\n", + " entry = {'listing': _meta_of(attrs), 'listing_url': where}\n", + " # 単体取得。ここだけが S3FileMetadataHeaders を通る。\n", + " link = links.get('move') or links.get('download')\n", + " if link:\n", + " url = meta_url_of(link.split('?')[0])\n", + " status, body = await wb_get_json(page, url)\n", + " entry['headers_url'] = url\n", + " entry['headers_status'] = status\n", + " entry['headers'] = _meta_of(\n", + " (body.get('data') or {}).get('attributes', {}))\n", + " out[key] = entry\n", + " return out\n", + "\n", + "\n", + "def _s3_expected_of(s3_before, key):\n", + " \"\"\"metadata_a_s3 の 1 件を md5 / size の期待値に直す。\n", + "\n", + " develop 版の WB は S3 の ETag をそのまま md5 として返す\n", + " (waterbutler/providers/s3/metadata.py)ので、引用符を外した ETag が\n", + " md5 の期待値になる。\n", + " \"\"\"\n", + " e = (s3_before or {}).get(key) or {}\n", + " if not e.get('etag_s3'):\n", + " return None\n", + " return {'md5': e['etag_s3'], 'size': e.get('size'), 's3_key': e.get('key')}\n", + "\n", + "\n", + "def metadata_basis(before, after, s3_before=None):\n", + " \"\"\"キーごとに、何を md5 の期待値にしたかを返す。\n", + "\n", + " - `phase-a`: phase a で WB から実測した値(metadata_a)\n", + " - `s3-etag`: phase a で boto3 から採った S3 の ETag(metadata_a_s3)\n", + " - `none`: どちらも無い(比べられない)\n", + " \"\"\"\n", + " out = {}\n", + " for key in sorted(set(before) | set(after) | set(s3_before or {})):\n", + " b = before.get(key) or {}\n", + " if b.get('listing') or b.get('headers'):\n", + " out[key] = 'phase-a'\n", + " elif _s3_expected_of(s3_before, key):\n", + " out[key] = 's3-etag'\n", + " else:\n", + " out[key] = 'none'\n", + " return out\n", + "\n", + "\n", + "def _quoted_and_cross_checks(key, a, basis):\n", + " \"\"\"引用符付き ETag と、一覧側 vs 単体取得側の食い違い。期待値に依らない。\"\"\"\n", + " out = []\n", + " for side in ('listing', 'headers'):\n", + " as_ = a.get(side) or {}\n", + " if as_ and (has_quotes(as_.get('etag')) or has_quotes(as_.get('md5'))):\n", + " out.append({'key': key, 'side': side, 'basis': basis,\n", + " 'problem': 'quoted-etag',\n", + " 'etag': as_.get('etag'), 'md5': as_.get('md5')})\n", + " la, ha = (a.get('listing') or {}), (a.get('headers') or {})\n", + " if ha and la.get('kind') != 'folder':\n", + " for field in ('etag', 'md5'):\n", + " if _unquote(la.get(field)) != _unquote(ha.get(field)):\n", + " out.append({'key': key, 'side': 'listing-vs-headers',\n", + " 'basis': basis, 'field': field,\n", + " 'listing': la.get(field),\n", + " 'headers': ha.get(field)})\n", + " return out\n", + "\n", + "\n", + "def metadata_diff(before, after, s3_before=None):\n", " \"\"\"phase a と phase b のメタデータを突き合わせる。\n", "\n", + " - 期待値は phase a の実測(`before`)が本筋。`before` に無いキーは\n", + " phase a で S3 から直接採った `s3_before`(metadata_a_s3)の ETag を\n", + " md5 の期待値に使う。**どちらを使ったかは各行の `basis` に書く。**\n", " - `etag` / `md5` / `size` の不一致を出す(`etag` は引用符を落としてから比べる)\n", " - **引用符が残っていること自体を差分として出す**(落としてから比べると\n", " 隠れてしまうので、別の欄に記録する)\n", + " - 採れなかったもの(`error`)も差分として出す。黙って一致にしない\n", " \"\"\"\n", " diffs = []\n", - " for key in sorted(set(before) | set(after)):\n", + " basis = metadata_basis(before, after, s3_before)\n", + " for key in sorted(set(before) | set(after) | set(s3_before or {})):\n", " b, a = before.get(key), after.get(key)\n", - " if b is None or a is None:\n", - " diffs.append({'key': key,\n", + " if (b or {}).get('error') or (a or {}).get('error'):\n", + " diffs.append({'key': key, 'basis': basis[key],\n", + " 'problem': 'fetch-error',\n", + " 'before': (b or {}).get('error'),\n", + " 'after': (a or {}).get('error')})\n", + " continue\n", + " if a is None:\n", + " diffs.append({'key': key, 'basis': basis[key],\n", " 'problem': 'missing',\n", - " 'in_before': b is not None,\n", - " 'in_after': a is not None})\n", + " 'in_before': b is not None, 'in_after': False})\n", + " continue\n", + " if basis[key] == 'phase-a':\n", + " for side in ('listing', 'headers'):\n", + " bs, as_ = (b or {}).get(side) or {}, a.get(side) or {}\n", + " if not bs and not as_:\n", + " continue\n", + " for field in ('etag', 'md5', 'hashes_md5', 'size'):\n", + " bv, av = bs.get(field), as_.get(field)\n", + " if _unquote(bv) != _unquote(av):\n", + " diffs.append({'key': key, 'side': side, 'field': field,\n", + " 'basis': 'phase-a',\n", + " 'before': bv, 'after': av})\n", + " elif basis[key] == 's3-etag':\n", + " exp = _s3_expected_of(s3_before, key)\n", + " for side in ('listing', 'headers'):\n", + " as_ = a.get(side) or {}\n", + " if not as_:\n", + " continue\n", + " for field in ('md5', 'hashes_md5'):\n", + " if _unquote(as_.get(field)) != exp['md5']:\n", + " diffs.append({'key': key, 'side': side, 'field': field,\n", + " 'basis': 's3-etag',\n", + " 'expected': exp['md5'],\n", + " 'after': as_.get(field)})\n", + " if exp['size'] is not None and as_.get('size') is not None:\n", + " if int(as_['size']) != int(exp['size']):\n", + " diffs.append({'key': key, 'side': side, 'field': 'size',\n", + " 'basis': 's3-etag',\n", + " 'expected': exp['size'],\n", + " 'after': as_.get('size')})\n", + " else:\n", + " diffs.append({'key': key, 'basis': 'none',\n", + " 'problem': 'no-expected-value'})\n", " continue\n", - " for side in ('listing', 'headers'):\n", - " bs, as_ = b.get(side) or {}, a.get(side) or {}\n", - " if not bs and not as_:\n", - " continue\n", - " for field in ('etag', 'md5', 'hashes_md5', 'size'):\n", - " bv, av = bs.get(field), as_.get(field)\n", - " if _unquote(bv) != _unquote(av):\n", - " diffs.append({'key': key, 'side': side, 'field': field,\n", - " 'before': bv, 'after': av})\n", - " if has_quotes(as_.get('etag')) or has_quotes(as_.get('md5')):\n", - " diffs.append({'key': key, 'side': side,\n", - " 'problem': 'quoted-etag',\n", - " 'etag': as_.get('etag'), 'md5': as_.get('md5')})\n", - " # 一覧側と単体取得側が互いに食い違っていないか(同じ段の中での突合)\n", - " la, ha = (a.get('listing') or {}), (a.get('headers') or {})\n", - " if ha and la.get('kind') != 'folder':\n", - " for field in ('etag', 'md5'):\n", - " if _unquote(la.get(field)) != _unquote(ha.get(field)):\n", - " diffs.append({'key': key, 'side': 'listing-vs-headers',\n", - " 'field': field,\n", - " 'listing': la.get(field),\n", - " 'headers': ha.get(field)})\n", + " diffs.extend(_quoted_and_cross_checks(key, a, basis[key]))\n", " return diffs\n", "\n", "\n", @@ -1877,6 +2050,24 @@ " link = links.get('download') or links.get('move')\n", " if link:\n", " out[key] = await download_sha256(page, link)\n", + " return out\n", + "\n", + "\n", + "async def collect_downloads_paths(page, root, rel_paths):\n", + " \"\"\"控えのパスぶんだけダウンロードして SHA-256 を採る。\"\"\"\n", + " out, cache = {}, {}\n", + " for rel in rel_paths:\n", + " attrs, links, where = await resolve_file(page, root, rel, cache)\n", + " key = '/' + rel\n", + " if attrs is None:\n", + " out[key] = {'error': where}\n", + " continue\n", + " link = links.get('download') or links.get('move')\n", + " if not link:\n", + " out[key] = {'error': 'ダウンロードの URL が一覧に無い'}\n", + " continue\n", + " print(f' DL {key} ({attrs.get(\"size\")} bytes)')\n", + " out[key] = await download_sha256(page, link)\n", " return out" ], "id": "cell-019", @@ -2362,27 +2553,24 @@ "\n", " async def _step(page):\n", " global a_meta, a_down\n", - " # 機関ストレージ(osfstorage 経由)\n", - " await open_project_files(page, p_inst)\n", - " wb_responses.clear()\n", - " await open_institutional_storage(page)\n", - " _root = wb_root_url('osfstorage')\n", - " a_meta['inst'] = await collect_metadata(page, _root)\n", - " a_down['inst'] = await collect_downloads(page, _root)\n", - " # S3 アドオン\n", - " await open_project_files(page, p_addon)\n", - " wb_responses.clear()\n", - " await open_storage(page, addon_storage_name)\n", - " _root = wb_root_url(addon_storage_id)\n", - " a_meta['addon'] = await collect_metadata(page, _root)\n", - " a_down['addon'] = await collect_downloads(page, _root)\n", - " # バージョン付き\n", - " await open_project_files(page, p_ver)\n", - " wb_responses.clear()\n", - " await open_storage(page, addon_storage_name)\n", - " _root = wb_root_url(addon_storage_id)\n", - " a_meta['ver'] = await collect_metadata(page, _root)\n", - " a_down['ver'] = await collect_downloads(page, _root)\n", + " for _scope, _project, _provider, _opener in (\n", + " ('inst', p_inst, 'osfstorage', open_institutional_storage),\n", + " ('addon', p_addon, addon_storage_id, None),\n", + " ('ver', p_ver, addon_storage_id, None)):\n", + " await open_project_files(page, _project)\n", + " # wb_responses を空にするのは証跡を段ごとに分けるためだけ。\n", + " # ルートの決定には使わない(contextVars から採る)。\n", + " wb_responses.clear()\n", + " if _opener is None:\n", + " await open_storage(page, addon_storage_name)\n", + " else:\n", + " await _opener(page)\n", + " _root = await wb_provider_root(page, _provider)\n", + " _paths = scope_rel_paths(_scope)\n", + " print(f'{_scope}: {_root}')\n", + " print(f' 控えにあるパス {len(_paths)} 件: {_paths}')\n", + " a_meta[_scope] = await collect_metadata_paths(page, _root, _paths)\n", + " a_down[_scope] = await collect_downloads_paths(page, _root, _paths)\n", "\n", " await run_pw(_step)\n", " keep_all('S-10a-metadata-fetch.json')\n", @@ -2435,6 +2623,89 @@ "outputs": [], "execution_count": null }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-032a", + "metadata": {}, + "outputs": [], + "source": [ + "# phase a の基準値(S3 側の実測)。**phase を見ないので単独で実行できる。**\n", + "# phase a を流した run のカーネルに、後からこのセルだけを足して実行してよい。\n", + "#\n", + "# なぜ要るか: WB 経由のメタデータ採取が取りこぼしたキーでも、S3 を直接\n", + "# 見れば md5 の期待値が作れる。develop 版の WaterButler は S3 の ETag を\n", + "# そのまま md5 として返す(waterbutler/providers/s3/metadata.py の\n", + "# S3FileMetadata.extra / S3FileMetadataHeaders.extra がどちらも\n", + "# `md5 = self.raw['ETag'].replace('\"', '')`)。つまり**引用符を外した\n", + "# ETag がそのまま md5 の期待値**になる。200MB のようなマルチパートの\n", + "# オブジェクトは `-<パート数>` が付く。\n", + "#\n", + "# 応答 JSON の `etag` のほうは WB が sha256('::') に\n", + "# 作り直した値(waterbutler/core/metadata.py)なので、S3 の ETag とは\n", + "# 直接比べられない。比べるのは extra.md5 / extra.hashes.md5。\n", + "_s3_now = {}\n", + "_s3_buckets = state.get('buckets', {})\n", + "_s3_region = _s3_buckets.get('region') or s3_region_default\n", + "# 段ごとのバケット。phase a の仕込みと同じ割り当て。\n", + "_scope_bucket = {\n", + " 'inst': _s3_buckets.get('mig'),\n", + " 'addon': _s3_buckets.get('mig'),\n", + " 'ver': _s3_buckets.get('migver'),\n", + "}\n", + "\n", + "\n", + "def _s3_key_of(scope, rel, info):\n", + " \"\"\"段ごとの S3 のキー。\n", + "\n", + " - S3 アドオン(addon / ver)はバケット直下にファイルのパスがそのまま\n", + " 入る。`S-10-raw/a+b %25 c~d.txt` のような生のキーもそのまま。\n", + " - 機関ストレージ(inst)は osfstorage 経由なので、オブジェクト名は\n", + " **中身の SHA-256** になる(waterbutler/providers/osfstorage/provider.py\n", + " の `complete_name = stream.writers['sha256'].hexdigest`)。控えに\n", + " 記録してある sha256 がそのままキー。\n", + " \"\"\"\n", + " return info.get('sha256') if scope == 'inst' else rel\n", + "\n", + "\n", + "for _key, _info in sorted(state.get('files', {}).items()):\n", + " if '/' not in _key:\n", + " continue\n", + " _scope, _rel = _key.split('/', 1)\n", + " _path = '/' + _rel\n", + " _bucket = _scope_bucket.get(_scope)\n", + " _s3key = _s3_key_of(_scope, _rel, _info)\n", + " _entry = {'scope': _scope, 'bucket': _bucket, 'key': _s3key}\n", + " if not _bucket or not _s3key:\n", + " _entry['error'] = f'バケットかキーが控えから決まらない({_key})'\n", + " else:\n", + " try:\n", + " # バージョン付きバケットでも VersionId を渡さなければ最新版。\n", + " _h = s3_client(_s3_region).head_object(Bucket=_bucket, Key=_s3key)\n", + " except Exception as _e:\n", + " _entry['error'] = _redact(repr(_e))\n", + " else:\n", + " _entry['etag_raw'] = _h['ETag']\n", + " _entry['etag_s3'] = _h['ETag'].replace('\"', '')\n", + " _entry['size'] = _h['ContentLength']\n", + " _entry['multipart'] = '-' in _entry['etag_s3']\n", + " _entry['version_id'] = _h.get('VersionId')\n", + " _s3_now.setdefault(_scope, {})[_path] = _entry\n", + "\n", + "record_json('S-10a-metadata-s3.json', _s3_now)\n", + "if state.get('metadata_a_s3') and phase != 'a':\n", + " print('[!] 控えに metadata_a_s3 が既にある。phase a の基準値は上書きしない'\n", + " '(採り直した値はこの run の証跡にだけ残す)')\n", + "else:\n", + " state['metadata_a_s3'] = _s3_now\n", + " save_state(state)\n", + "for _scope, _items in sorted(_s3_now.items()):\n", + " print(f'{_scope}: バケット {_scope_bucket.get(_scope)}')\n", + " for _p, _v in sorted(_items.items()):\n", + " print(f' {_p} -> {_v.get(\"etag_s3\") or _v.get(\"error\")}'\n", + " f' / {_v.get(\"size\")} bytes')" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -2550,10 +2821,12 @@ " await open_storage(page, addon_storage_name)\n", " else:\n", " await _opener(page)\n", - " _root = wb_root_url(_provider)\n", + " _root = await wb_provider_root(page, _provider)\n", " b_listing[_scope] = listing_pages()\n", - " b_meta[_scope] = await collect_metadata(page, _root)\n", - " b_down[_scope] = await collect_downloads(page, _root)\n", + " _paths = scope_rel_paths(_scope)\n", + " print(f'{_scope}: {_root}')\n", + " b_meta[_scope] = await collect_metadata_paths(page, _root, _paths)\n", + " b_down[_scope] = await collect_downloads_paths(page, _root, _paths)\n", " # 302 → presigned は UI のツールバーからのダウンロードでしか出ない。\n", " # 代表 1 件だけ踏む。\n", " try:\n", @@ -2591,15 +2864,31 @@ " for _scope, _items in _a_meta.items()}\n", " record_json('S-10b-listing-diff.json',\n", " {'missing': _missing, 'extra': _extra}, quiet=False)\n", - " b_record('S-10b-3', not any(_missing.values()),\n", + " # 控え駆動の採取なので、引けなかったものは error として残っている。\n", + " _unreadable = {_scope: sorted(k for k, v in _items.items()\n", + " if (v or {}).get('error'))\n", + " for _scope, _items in b_meta.items()}\n", + " b_record('S-10b-3',\n", + " not any(_missing.values()) and not any(_unreadable.values()),\n", " f'欠落 {sum(len(v) for v in _missing.values())} 件 / '\n", + " f'引けなかったもの {sum(len(v) for v in _unreadable.values())} 件 / '\n", " f'phase a に無かったもの {sum(len(v) for v in _extra.values())} 件')\n", "\n", " # S-10b-4: メタデータの突合。引用符付き ETag はそれ自体を差分として出す。\n", + " # 期待値は phase a の実測(metadata_a)が本筋。phase a が取りこぼした\n", + " # キーは、同じ phase a で S3 から直接採った metadata_a_s3 の ETag を\n", + " # md5 の期待値に使う。どちらを使ったかは basis に残す。\n", + " _a_s3 = state.get('metadata_a_s3', {})\n", " _diffs = {}\n", - " for _scope, _items in _a_meta.items():\n", - " _diffs[_scope] = metadata_diff(_items, b_meta.get(_scope, {}))\n", - " record_json('S-10b-metadata-diff.json', _diffs, quiet=False)\n", + " _basis = {}\n", + " for _scope in sorted(set(_a_meta) | set(_a_s3) | set(b_meta)):\n", + " _items = _a_meta.get(_scope, {})\n", + " _s3 = _a_s3.get(_scope, {})\n", + " _diffs[_scope] = metadata_diff(_items, b_meta.get(_scope, {}), _s3)\n", + " _basis[_scope] = metadata_basis(_items, b_meta.get(_scope, {}), _s3)\n", + " record_json('S-10b-metadata-diff.json',\n", + " {'diffs': _diffs, 'basis': _basis,\n", + " 'unreadable': _unreadable}, quiet=False)\n", " _quoted = [d for ds in _diffs.values() for d in ds\n", " if d.get('problem') == 'quoted-etag']\n", " _n_diff = sum(len(v) for v in _diffs.values())\n", @@ -2619,17 +2908,29 @@ " f'S3 側の ETag {_now_etag} / phase a {_exp[\"etag\"]}')\n", "\n", " # S-10b-5: ダウンロードの SHA-256。\n", + " # 期待値は phase a のダウンロード(downloads_a)が本筋。phase a が\n", + " # 取りこぼしたキーは、控えに残した**投入時の SHA-256**を期待値にする。\n", " _a_down = state.get('downloads_a', {})\n", + " _files = state.get('files', {})\n", " _mismatch = []\n", - " for _scope, _items in _a_down.items():\n", - " for _key, _before in _items.items():\n", + " for _scope in sorted(set(_a_down) | set(b_down)):\n", + " for _key in sorted(set(_a_down.get(_scope) or {})\n", + " | set(b_down.get(_scope) or {})):\n", + " _before = (_a_down.get(_scope) or {}).get(_key) or {}\n", " _now = (b_down.get(_scope) or {}).get(_key)\n", - " if _now is None:\n", + " _want = _before.get('sha256') or (\n", + " _files.get(f'{_scope}{_key}') or {}).get('sha256')\n", + " if _now is None or _now.get('error'):\n", + " _mismatch.append({'scope': _scope, 'key': _key,\n", + " 'problem': 'not-downloadable',\n", + " 'error': (_now or {}).get('error')})\n", + " elif _want is None:\n", " _mismatch.append({'scope': _scope, 'key': _key,\n", - " 'problem': 'not-downloadable'})\n", - " elif _now['sha256'] != _before['sha256']:\n", + " 'problem': 'no-expected-value',\n", + " 'after': _now})\n", + " elif _now.get('sha256') != _want:\n", " _mismatch.append({'scope': _scope, 'key': _key,\n", - " 'before': _before, 'after': _now})\n", + " 'expected': _want, 'after': _now})\n", " record_json('S-10b-downloads-diff.json', _mismatch, quiet=False)\n", " b_record('S-10b-5', not _mismatch, f'不一致 {len(_mismatch)} 件')\n", "\n", @@ -2843,7 +3144,7 @@ " await open_project_files(page, p_addon)\n", " wb_responses.clear()\n", " await open_storage(page, addon_storage_name)\n", - " _root = wb_root_url(addon_storage_id)\n", + " _root = await wb_provider_root(page, addon_storage_id)\n", " _new_meta = await collect_metadata(page, _root)\n", " _new_down = await collect_downloads(page, _root)\n", "\n", @@ -2879,7 +3180,7 @@ " await open_project_files(page, p_dot)\n", " wb_responses.clear()\n", " await open_storage(page, addon_storage_name)\n", - " _root = wb_root_url(addon_storage_id)\n", + " _root = await wb_provider_root(page, addon_storage_id)\n", " _dot_b['metadata'] = await collect_metadata(page, _root)\n", " _dot_b['downloads'] = await collect_downloads(page, _root)\n", "\n", @@ -3044,7 +3345,7 @@ " await open_project_files(page, p_addon)\n", " wb_responses.clear()\n", " await open_storage(page, addon_storage_name)\n", - " _root = wb_root_url(addon_storage_id)\n", + " _root = await wb_provider_root(page, addon_storage_id)\n", " c_meta['addon'] = await collect_metadata(page, _root)\n", " c_down['addon'] = await collect_downloads(page, _root)\n", "\n", From b17bf033508cae81993a7937a4ab40c958563724 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Sun, 18 Oct 2026 16:22:37 +0900 Subject: [PATCH 25/26] test(s3): fix four phase b verdicts that failed on sound data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 機関ストレージ(osfstorage)は md5 を extra.hashes.md5 にしか入れず extra.md5 は None になる。突合が extra.md5 だけを見ていたので、値は 期待値と一致しているのに inst の全件が差分として出ていた。md5 が None のときは hashes_md5 で比べるようにした。一覧側と単体側、および その 2 つの食い違いを見るところも同じ規則にした。 S-10b-3 の「phase a に無かったもの」は、期待値の基準が s3-etag の キーまで数えていた。これは phase a が WB では採れず S3 からは採れて いるキーで、増えたわけではない。基準が s3-etag のものは数えず、 残りを参考情報として出す。判定は従来どおり「一覧に欠落」と 「引けなかった」の件数だけで行う。 再実行に耐えない判定を 2 つ直した。S-10b-7 は削除前の版が 0 のとき (前の run で削除済み)に UI 削除を試さず、再確認していないことを 明示して先へ進む。S-10b-11 は接続済みのプロジェクトでも connect_s3_addon がバケット選択から続けられるので、そのまま通して どの入り口から入ったかを証跡に残す。 --- ...350\241\214\344\272\222\346\217\233.ipynb" | 157 +++++++++++++----- 1 file changed, 114 insertions(+), 43 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" index a7d8fc0..2fe5c68 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" @@ -1552,7 +1552,9 @@ " except AssertionError:\n", " raise await no_addon_entry_point(page)\n", "\n", + " entered_by = None\n", " if await connect_link.count() > 0:\n", + " entered_by = 'connect'\n", " await connect_link.first.click()\n", " await expect(input_creds.locator('.btn-success')).to_be_enabled(\n", " timeout=transition_timeout)\n", @@ -1567,9 +1569,11 @@ " 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", + " entered_by = 'import'\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", + " entered_by = 'already-connected'\n", " print('[i] 既にアカウントが接続済み。バケット選択から続ける')\n", " await page.locator(\n", " '//button[.//span[text() = \"変更\" or text() = \"Change\"]]').click(\n", @@ -1600,7 +1604,11 @@ " await expect(grdm.get_select_expanded_storage_title_locator(\n", " page, addon_storage_name)).to_be_visible(timeout=transition_timeout)\n", " await asyncio.sleep(2)\n", - " print(f'{project_name}: {addon_storage_name} アドオンを接続した')" + " print(f'{project_name}: {addon_storage_name} アドオンを接続した'\n", + " f'(入り口: {entered_by})')\n", + " # 呼び出し側が「既に接続済みだった」ことを証跡に書けるように返す。\n", + " # 戻り値を使わない呼び出しはそのままで差し支えない。\n", + " return entered_by" ], "id": "cell-017", "outputs": [], @@ -1710,6 +1718,23 @@ " return value.replace('\"', '') if isinstance(value, str) else value\n", "\n", "\n", + "def _md5_of(meta):\n", + " \"\"\"比べるべき md5 を 1 つ返す。無ければ None。\n", + "\n", + " S3 アドオン(S3FileMetadata / S3FileMetadataHeaders)は `extra.md5` と\n", + " `extra.hashes.md5` の両方に同じ値を入れるが、**機関ストレージ\n", + " (osfstorage)は `extra.hashes.md5` にしか入れず `extra.md5` は\n", + " None** になる。`md5` だけを見ると osfstorage の全件が「期待値と違う」に\n", + " なってしまうので、None のときは `hashes_md5` を見る。\n", + " \"\"\"\n", + " meta = meta or {}\n", + " for field in ('md5', 'hashes_md5'):\n", + " value = _unquote(meta.get(field))\n", + " if value is not None:\n", + " return value\n", + " return None\n", + "\n", + "\n", "def has_quotes(value):\n", " return isinstance(value, str) and '\"' in value\n", "\n", @@ -1949,12 +1974,17 @@ " 'etag': as_.get('etag'), 'md5': as_.get('md5')})\n", " la, ha = (a.get('listing') or {}), (a.get('headers') or {})\n", " if ha and la.get('kind') != 'folder':\n", - " for field in ('etag', 'md5'):\n", - " if _unquote(la.get(field)) != _unquote(ha.get(field)):\n", - " out.append({'key': key, 'side': 'listing-vs-headers',\n", - " 'basis': basis, 'field': field,\n", - " 'listing': la.get(field),\n", - " 'headers': ha.get(field)})\n", + " if _unquote(la.get('etag')) != _unquote(ha.get('etag')):\n", + " out.append({'key': key, 'side': 'listing-vs-headers',\n", + " 'basis': basis, 'field': 'etag',\n", + " 'listing': la.get('etag'),\n", + " 'headers': ha.get('etag')})\n", + " # md5 も `md5` が None なら `hashes_md5` で比べる(osfstorage)。\n", + " if _md5_of(la) != _md5_of(ha):\n", + " out.append({'key': key, 'side': 'listing-vs-headers',\n", + " 'basis': basis, 'field': 'md5',\n", + " 'listing': _md5_of(la),\n", + " 'headers': _md5_of(ha)})\n", " return out\n", "\n", "\n", @@ -1965,6 +1995,8 @@ " phase a で S3 から直接採った `s3_before`(metadata_a_s3)の ETag を\n", " md5 の期待値に使う。**どちらを使ったかは各行の `basis` に書く。**\n", " - `etag` / `md5` / `size` の不一致を出す(`etag` は引用符を落としてから比べる)\n", + " - md5 は `extra.md5` が None なら `extra.hashes.md5` で比べる。機関\n", + " ストレージ(osfstorage)は後者にしか md5 を入れない\n", " - **引用符が残っていること自体を差分として出す**(落としてから比べると\n", " 隠れてしまうので、別の欄に記録する)\n", " - 採れなかったもの(`error`)も差分として出す。黙って一致にしない\n", @@ -1989,24 +2021,34 @@ " bs, as_ = (b or {}).get(side) or {}, a.get(side) or {}\n", " if not bs and not as_:\n", " continue\n", - " for field in ('etag', 'md5', 'hashes_md5', 'size'):\n", + " for field in ('etag', 'size'):\n", " bv, av = bs.get(field), as_.get(field)\n", " if _unquote(bv) != _unquote(av):\n", " diffs.append({'key': key, 'side': side, 'field': field,\n", " 'basis': 'phase-a',\n", " 'before': bv, 'after': av})\n", + " # md5 は md5 / hashes_md5 のどちらか出ているほうで比べる。\n", + " if _md5_of(bs) != _md5_of(as_):\n", + " diffs.append({'key': key, 'side': side, 'field': 'md5',\n", + " 'basis': 'phase-a',\n", + " 'before': _md5_of(bs), 'after': _md5_of(as_),\n", + " 'before_raw': {'md5': bs.get('md5'),\n", + " 'hashes_md5': bs.get('hashes_md5')},\n", + " 'after_raw': {'md5': as_.get('md5'),\n", + " 'hashes_md5': as_.get('hashes_md5')}})\n", " elif basis[key] == 's3-etag':\n", " exp = _s3_expected_of(s3_before, key)\n", " for side in ('listing', 'headers'):\n", " as_ = a.get(side) or {}\n", " if not as_:\n", " continue\n", - " for field in ('md5', 'hashes_md5'):\n", - " if _unquote(as_.get(field)) != exp['md5']:\n", - " diffs.append({'key': key, 'side': side, 'field': field,\n", - " 'basis': 's3-etag',\n", - " 'expected': exp['md5'],\n", - " 'after': as_.get(field)})\n", + " if _md5_of(as_) != exp['md5']:\n", + " diffs.append({'key': key, 'side': side, 'field': 'md5',\n", + " 'basis': 's3-etag',\n", + " 'expected': exp['md5'],\n", + " 'after': _md5_of(as_),\n", + " 'after_raw': {'md5': as_.get('md5'),\n", + " 'hashes_md5': as_.get('hashes_md5')}})\n", " if exp['size'] is not None and as_.get('size') is not None:\n", " if int(as_['size']) != int(exp['size']):\n", " diffs.append({'key': key, 'side': side, 'field': 'size',\n", @@ -2856,14 +2898,27 @@ "if phase == 'b':\n", " # S-10b-3: phase a で置いたキーが欠落・文字化けなく出ているか。\n", " _a_meta = state.get('metadata_a', {})\n", + " _a_s3 = state.get('metadata_a_s3', {})\n", + " _scopes = sorted(set(_a_meta) | set(_a_s3) | set(b_meta))\n", + " _basis = {_s: metadata_basis(_a_meta.get(_s, {}), b_meta.get(_s, {}),\n", + " _a_s3.get(_s, {}))\n", + " for _s in _scopes}\n", " _missing = {}\n", - " for _scope, _items in _a_meta.items():\n", + " for _scope in _scopes:\n", " _now = set(b_meta.get(_scope, {}))\n", - " _missing[_scope] = sorted(set(_items) - _now)\n", - " _extra = {_scope: sorted(set(b_meta.get(_scope, {})) - set(_items))\n", - " for _scope, _items in _a_meta.items()}\n", + " _missing[_scope] = sorted(set(_a_meta.get(_scope, {})) - _now)\n", + " # 「phase a に無かった」は参考情報。**判定には使わない。** 期待値の基準が\n", + " # s3-etag のキーは、phase a が WB では採れなかっただけで S3 からは採れて\n", + " # いる(metadata_a_s3 に入っている)ので、ここには数えない。数えると\n", + " # 控えを補正した run がまるごと「増えた」ことになってしまう。\n", + " _extra = {}\n", + " for _scope in _scopes:\n", + " _extra[_scope] = sorted(\n", + " k for k in set(b_meta.get(_scope, {})) - set(_a_meta.get(_scope, {}))\n", + " if _basis[_scope].get(k) != 's3-etag')\n", " record_json('S-10b-listing-diff.json',\n", - " {'missing': _missing, 'extra': _extra}, quiet=False)\n", + " {'missing': _missing, 'extra': _extra,\n", + " 'basis': _basis}, quiet=False)\n", " # 控え駆動の採取なので、引けなかったものは error として残っている。\n", " _unreadable = {_scope: sorted(k for k, v in _items.items()\n", " if (v or {}).get('error'))\n", @@ -2871,21 +2926,19 @@ " b_record('S-10b-3',\n", " not any(_missing.values()) and not any(_unreadable.values()),\n", " f'欠落 {sum(len(v) for v in _missing.values())} 件 / '\n", - " f'引けなかったもの {sum(len(v) for v in _unreadable.values())} 件 / '\n", - " f'phase a に無かったもの {sum(len(v) for v in _extra.values())} 件')\n", + " f'引けなかったもの {sum(len(v) for v in _unreadable.values())} 件'\n", + " f'(参考: phase a に無かったもの '\n", + " f'{sum(len(v) for v in _extra.values())} 件。判定には使わない)')\n", "\n", " # S-10b-4: メタデータの突合。引用符付き ETag はそれ自体を差分として出す。\n", " # 期待値は phase a の実測(metadata_a)が本筋。phase a が取りこぼした\n", " # キーは、同じ phase a で S3 から直接採った metadata_a_s3 の ETag を\n", " # md5 の期待値に使う。どちらを使ったかは basis に残す。\n", - " _a_s3 = state.get('metadata_a_s3', {})\n", " _diffs = {}\n", - " _basis = {}\n", - " for _scope in sorted(set(_a_meta) | set(_a_s3) | set(b_meta)):\n", + " for _scope in _scopes:\n", " _items = _a_meta.get(_scope, {})\n", " _s3 = _a_s3.get(_scope, {})\n", " _diffs[_scope] = metadata_diff(_items, b_meta.get(_scope, {}), _s3)\n", - " _basis[_scope] = metadata_basis(_items, b_meta.get(_scope, {}), _s3)\n", " record_json('S-10b-metadata-diff.json',\n", " {'diffs': _diffs, 'basis': _basis,\n", " 'unreadable': _unreadable}, quiet=False)\n", @@ -3055,6 +3108,11 @@ " _before = list_versions(s3_bucket_migver, f_ver)\n", " record_json('S-10b-versions-before.json', _before, quiet=False)\n", "\n", + " # 前の run で削除済みのとき(版が 1 つも無い)。UI から消すものが無いので\n", + " # 削除操作は試さない。試すと「ファイルが無い」で落ちるだけで、何も\n", + " # 確かめられない。再実行では**この項目は確認できない**ことを明示する。\n", + " _already_deleted = _before['version_count'] == 0\n", + "\n", " async def _step(page):\n", " await open_project_files(page, p_ver)\n", " wb_responses.clear()\n", @@ -3063,22 +3121,28 @@ " # 削除は非同期ではないが、S3 の一覧が追いつくまで少し待つ\n", " await asyncio.sleep(5)\n", "\n", - " try:\n", - " await run_pw(_step)\n", - " _err = None\n", - " except Exception as _e:\n", - " _err = repr(_e)\n", - " print(f'[!] 削除操作で例外: {_e!r}(記録して続行)')\n", - " keep_all('S-10b-versions-wb.json')\n", - "\n", - " _after = list_versions(s3_bucket_migver, f_ver)\n", - " record_json('S-10b-versions-after.json', _after, quiet=False)\n", - " b_record('S-10b-7',\n", - " _after['version_count'] == 0 and _after['delete_marker_count'] == 0,\n", - " f'削除前 版 {_before[\"version_count\"]} / 削除後 版 '\n", - " f'{_after[\"version_count\"]} + 削除マーカー '\n", - " f'{_after[\"delete_marker_count\"]}'\n", - " + (f' / 例外 {_err}' if _err else ''))" + " if _already_deleted:\n", + " print('[i] 削除前の版が 0。前の run で削除済みとみなして UI 削除は飛ばす')\n", + " b_record('S-10b-7', True,\n", + " '前の run で削除済み(before=0)。この run では再確認なし')\n", + " else:\n", + " try:\n", + " await run_pw(_step)\n", + " _err = None\n", + " except Exception as _e:\n", + " _err = repr(_e)\n", + " print(f'[!] 削除操作で例外: {_e!r}(記録して続行)')\n", + " keep_all('S-10b-versions-wb.json')\n", + "\n", + " _after = list_versions(s3_bucket_migver, f_ver)\n", + " record_json('S-10b-versions-after.json', _after, quiet=False)\n", + " b_record('S-10b-7',\n", + " _after['version_count'] == 0\n", + " and _after['delete_marker_count'] == 0,\n", + " f'削除前 版 {_before[\"version_count\"]} / 削除後 版 '\n", + " f'{_after[\"version_count\"]} + 削除マーカー '\n", + " f'{_after[\"delete_marker_count\"]}'\n", + " + (f' / 例外 {_err}' if _err else ''))" ], "id": "cell-039", "outputs": [], @@ -3215,7 +3279,11 @@ "\n", " async def _step(page):\n", " await create_project_and_open_files(page, p_newconn)\n", - " await connect_s3_addon(page, p_newconn, s3_bucket_mig)\n", + " # 再実行では既にアカウントが接続済みのことがある。connect_s3_addon は\n", + " # その分岐でもバケット選択から続けるので、そのまま通してよい。\n", + " # どの入り口から入ったかだけ証跡に残す。\n", + " _newconn['connected_via'] = await connect_s3_addon(\n", + " page, p_newconn, s3_bucket_mig)\n", " wb_responses.clear()\n", " await open_storage(page, addon_storage_name)\n", " await upload_and_wait(page, _nc_path)\n", @@ -3253,9 +3321,12 @@ " 'where': 'newconn', 'name': f_newconn, 'placed_in_phase': 'b',\n", " 'sha256': _newconn['sha256'], 'size': os.path.getsize(_nc_path)}\n", " save_state(state)\n", + " _via = _newconn.get('connected_via')\n", " b_record('S-10b-11', bool(_newconn.get('ok')),\n", " f'新規接続 {\"成功\" if _newconn.get(\"ok\") else \"失敗\"} / '\n", - " f'S3 側 {_newconn.get(\"s3\")}')\n", + " + ('既にアカウント接続済みのプロジェクトで実施(再実行)'\n", + " if _via == 'already-connected' else f'入り口 {_via}')\n", + " + f' / S3 側 {_newconn.get(\"s3\")}')\n", " print('`folder_id` の保存形は S-10b-newconn.json の addon_page_text と、'\n", " '管理 API の node settings で確認すること')" ], From d7023f15c0796944402f79791f898a6e9f3d04ee Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Sun, 18 Oct 2026 20:56:41 +0900 Subject: [PATCH 26/26] test(s3): make the phase b checks survive a second run Re-running phase b against a bucket that a previous run already touched made three checks fail for reasons that have nothing to do with the code under test. - The versioned file that the delete check removes is gone on the next run, so the listing, metadata and download checks counted it as missing and unreadable. Exclude it when the versioned bucket holds no version of it, and say why in the evidence. - Files placed by phase b itself were compared against the expected hashes that a *previous* run recorded, which no longer describe what is in the bucket. Exclude them from the phase a comparison and check them where they are written, against the values measured in the same run: the downloaded SHA-256 and size for the two new files, and size plus ETag from S3 for the versioned one. The result goes to S-10b-new-check.json. - The intra move/copy step clicked a row by file name. A leftover copy from an earlier run gives that name two rows, and strict mode then aborted the step before either the move or the copy was attempted. Take the first row and record how many matched, so a duplicate shows up in the evidence instead of stopping the check. The notebook was not executed; the cells were syntax-checked only. Both excluded-key rules were verified offline against recorded evidence from two runs. --- ...350\241\214\344\272\222\346\217\233.ipynb" | 179 +++++++++++++++--- 1 file changed, 149 insertions(+), 30 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" index 2fe5c68..449de40 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\347\247\273\350\241\214\344\272\222\346\217\233.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\347\247\273\350\241\214\344\272\222\346\217\233.ipynb" @@ -2899,13 +2899,49 @@ " # S-10b-3: phase a で置いたキーが欠落・文字化けなく出ているか。\n", " _a_meta = state.get('metadata_a', {})\n", " _a_s3 = state.get('metadata_a_s3', {})\n", - " _scopes = sorted(set(_a_meta) | set(_a_s3) | set(b_meta))\n", - " _basis = {_s: metadata_basis(_a_meta.get(_s, {}), b_meta.get(_s, {}),\n", + " _a_down = state.get('downloads_a', {})\n", + " _files = state.get('files', {})\n", + "\n", + " # 判定の対象から外すものを先に決める。どちらも「再実行だから合わない」\n", + " # だけで、試験対象の退行ではない。\n", + " # (1) b-7 で削除済みのキー: 版付きバケットに版が 1 つも無ければ、前の run の\n", + " # b-7 が消したということ。**消えているのが正しい**ので、欠落や\n", + " # 「引けなかったもの」に数えると再実行のたびに必ず落ちる。\n", + " # (2) phase b で置いたもの(placed_in_phase == 'b'): 控えの sha256 / size は\n", + " # **前の run の投入値**で、この run が b-8 で置き直す前の中身とは\n", + " # 一致しない。新データの突合は b-8 が同じ run の値で行う。\n", + " _excluded = {}\n", + "\n", + " def _exclude(scope, key, why):\n", + " _excluded.setdefault(scope, {})[key] = why\n", + "\n", + " if list_versions(s3_bucket_migver, f_ver)['version_count'] == 0:\n", + " _exclude('ver', f'/{f_ver}', 'b-7 で削除済み')\n", + " for _fkey, _finfo in sorted(_files.items()):\n", + " if _finfo.get('placed_in_phase') == 'b' and '/' in _fkey:\n", + " _fscope, _frel = _fkey.split('/', 1)\n", + " _exclude(_fscope, f'/{_frel}', 'phase b で投入(突合は b-8)')\n", + "\n", + " def _drop_excluded(per_scope):\n", + " \"\"\"scope ごとの dict から、判定対象外のキーを落とした写しを返す。\"\"\"\n", + " return {_s: {_k: _v for _k, _v in (_items or {}).items()\n", + " if _k not in _excluded.get(_s, {})}\n", + " for _s, _items in (per_scope or {}).items()}\n", + "\n", + " _a_meta = _drop_excluded(_a_meta)\n", + " _a_s3 = _drop_excluded(_a_s3)\n", + " _a_down = _drop_excluded(_a_down)\n", + " _b_meta = _drop_excluded(b_meta)\n", + " _b_down = _drop_excluded(b_down)\n", + " _n_excluded = sum(len(_v) for _v in _excluded.values())\n", + "\n", + " _scopes = sorted(set(_a_meta) | set(_a_s3) | set(_b_meta))\n", + " _basis = {_s: metadata_basis(_a_meta.get(_s, {}), _b_meta.get(_s, {}),\n", " _a_s3.get(_s, {}))\n", " for _s in _scopes}\n", " _missing = {}\n", " for _scope in _scopes:\n", - " _now = set(b_meta.get(_scope, {}))\n", + " _now = set(_b_meta.get(_scope, {}))\n", " _missing[_scope] = sorted(set(_a_meta.get(_scope, {})) - _now)\n", " # 「phase a に無かった」は参考情報。**判定には使わない。** 期待値の基準が\n", " # s3-etag のキーは、phase a が WB では採れなかっただけで S3 からは採れて\n", @@ -2914,21 +2950,22 @@ " _extra = {}\n", " for _scope in _scopes:\n", " _extra[_scope] = sorted(\n", - " k for k in set(b_meta.get(_scope, {})) - set(_a_meta.get(_scope, {}))\n", + " k for k in set(_b_meta.get(_scope, {})) - set(_a_meta.get(_scope, {}))\n", " if _basis[_scope].get(k) != 's3-etag')\n", " record_json('S-10b-listing-diff.json',\n", " {'missing': _missing, 'extra': _extra,\n", - " 'basis': _basis}, quiet=False)\n", + " 'basis': _basis, 'excluded': _excluded}, quiet=False)\n", " # 控え駆動の採取なので、引けなかったものは error として残っている。\n", " _unreadable = {_scope: sorted(k for k, v in _items.items()\n", " if (v or {}).get('error'))\n", - " for _scope, _items in b_meta.items()}\n", + " for _scope, _items in _b_meta.items()}\n", " b_record('S-10b-3',\n", " not any(_missing.values()) and not any(_unreadable.values()),\n", " f'欠落 {sum(len(v) for v in _missing.values())} 件 / '\n", " f'引けなかったもの {sum(len(v) for v in _unreadable.values())} 件'\n", " f'(参考: phase a に無かったもの '\n", - " f'{sum(len(v) for v in _extra.values())} 件。判定には使わない)')\n", + " f'{sum(len(v) for v in _extra.values())} 件。判定には使わない / '\n", + " f'対象外 {_n_excluded} 件)')\n", "\n", " # S-10b-4: メタデータの突合。引用符付き ETag はそれ自体を差分として出す。\n", " # 期待値は phase a の実測(metadata_a)が本筋。phase a が取りこぼした\n", @@ -2938,15 +2975,16 @@ " for _scope in _scopes:\n", " _items = _a_meta.get(_scope, {})\n", " _s3 = _a_s3.get(_scope, {})\n", - " _diffs[_scope] = metadata_diff(_items, b_meta.get(_scope, {}), _s3)\n", + " _diffs[_scope] = metadata_diff(_items, _b_meta.get(_scope, {}), _s3)\n", " record_json('S-10b-metadata-diff.json',\n", " {'diffs': _diffs, 'basis': _basis,\n", - " 'unreadable': _unreadable}, quiet=False)\n", + " 'unreadable': _unreadable, 'excluded': _excluded}, quiet=False)\n", " _quoted = [d for ds in _diffs.values() for d in ds\n", " if d.get('problem') == 'quoted-etag']\n", " _n_diff = sum(len(v) for v in _diffs.values())\n", " b_record('S-10b-4', _n_diff == 0,\n", - " f'差分 {_n_diff} 件(うち引用符付き ETag {len(_quoted)} 件)')\n", + " f'差分 {_n_diff} 件(うち引用符付き ETag {len(_quoted)} 件 / '\n", + " f'対象外 {_n_excluded} 件)')\n", "\n", " # 200MB のパート数。\n", " if run_chunked and state.get('expected', {}).get('chunked'):\n", @@ -2963,14 +3001,12 @@ " # S-10b-5: ダウンロードの SHA-256。\n", " # 期待値は phase a のダウンロード(downloads_a)が本筋。phase a が\n", " # 取りこぼしたキーは、控えに残した**投入時の SHA-256**を期待値にする。\n", - " _a_down = state.get('downloads_a', {})\n", - " _files = state.get('files', {})\n", " _mismatch = []\n", - " for _scope in sorted(set(_a_down) | set(b_down)):\n", + " for _scope in sorted(set(_a_down) | set(_b_down)):\n", " for _key in sorted(set(_a_down.get(_scope) or {})\n", - " | set(b_down.get(_scope) or {})):\n", + " | set(_b_down.get(_scope) or {})):\n", " _before = (_a_down.get(_scope) or {}).get(_key) or {}\n", - " _now = (b_down.get(_scope) or {}).get(_key)\n", + " _now = (_b_down.get(_scope) or {}).get(_key)\n", " _want = _before.get('sha256') or (\n", " _files.get(f'{_scope}{_key}') or {}).get('sha256')\n", " if _now is None or _now.get('error'):\n", @@ -2985,7 +3021,8 @@ " _mismatch.append({'scope': _scope, 'key': _key,\n", " 'expected': _want, 'after': _now})\n", " record_json('S-10b-downloads-diff.json', _mismatch, quiet=False)\n", - " b_record('S-10b-5', not _mismatch, f'不一致 {len(_mismatch)} 件')\n", + " b_record('S-10b-5', not _mismatch,\n", + " f'不一致 {len(_mismatch)} 件(対象外 {_n_excluded} 件)')\n", "\n", " # S-10b-2: 機関ストレージの一覧とダウンロード、302 → presigned。\n", " _inst_ok = (not _missing.get('inst') and\n", @@ -3036,16 +3073,36 @@ "\n", " b_intra = {}\n", "\n", + " async def first_row(locator, label):\n", + " \"\"\"同名の行が複数あっても止まらないよう先頭に絞る。\n", + "\n", + " 前の run の intra 試行のコピーが残っていると、同じ名前の行が\n", + " ルート直下とフォルダの中の 2 つ見える。strict mode で落ちると\n", + " move も copy も一度も試せずに終わるので、**件数を記録してから\n", + " 先頭を使う**。件数は S-10b-intra.json と判定の文言に残る。\n", + " \"\"\"\n", + " count = await locator.count()\n", + " b_intra.setdefault('row_counts', {})[label] = count\n", + " if count != 1:\n", + " print(f'[!] 「{label}」に一致する行が {count} 件。先頭を使う')\n", + " return locator.first\n", + "\n", " async def _step(page):\n", " await open_project_files(page, p_addon)\n", " wb_responses.clear()\n", " await open_storage(page, addon_storage_name)\n", " await clear_growls(page)\n", " # 移動: 素のドラッグ。対象は phase a で置いた記号入りの名前。\n", - " await grdm.get_select_file_extension_locator(page, f_symbols).click(\n", - " timeout=transition_timeout)\n", - " src = grdm.get_select_file_draggable_locator(page, f_symbols)\n", - " dst = grdm.get_select_folder_droppable_locator(page, folder_top)\n", + " _row = await first_row(\n", + " grdm.get_select_file_extension_locator(page, f_symbols),\n", + " f'move/file:{f_symbols}')\n", + " await _row.click(timeout=transition_timeout)\n", + " src = await first_row(\n", + " grdm.get_select_file_draggable_locator(page, f_symbols),\n", + " f'move/drag:{f_symbols}')\n", + " dst = await first_row(\n", + " grdm.get_select_folder_droppable_locator(page, folder_top),\n", + " f'move/folder:{folder_top}')\n", " await grdm.drag_and_drop(page, src, dst)\n", " b_intra['move'] = {'notified': await wait_file_op_notified(page, '移動')}\n", " await asyncio.sleep(3)\n", @@ -3055,13 +3112,21 @@ " # コピー: Alt + ドラッグで元の場所に戻す\n", " wb_responses.clear()\n", " await clear_growls(page)\n", - " await grdm.get_select_folder_title_locator(page, folder_top).click(\n", - " timeout=transition_timeout)\n", + " _row = await first_row(\n", + " grdm.get_select_folder_title_locator(page, folder_top),\n", + " f'copy/folder-title:{folder_top}')\n", + " await _row.click(timeout=transition_timeout)\n", " await asyncio.sleep(2)\n", - " await grdm.get_select_file_extension_locator(page, f_symbols).click(\n", - " timeout=transition_timeout)\n", - " src = grdm.get_select_file_draggable_locator(page, f_symbols)\n", - " dst = grdm.get_select_storage_title_locator(page, addon_storage_name)\n", + " _row = await first_row(\n", + " grdm.get_select_file_extension_locator(page, f_symbols),\n", + " f'copy/file:{f_symbols}')\n", + " await _row.click(timeout=transition_timeout)\n", + " src = await first_row(\n", + " grdm.get_select_file_draggable_locator(page, f_symbols),\n", + " f'copy/drag:{f_symbols}')\n", + " dst = await first_row(\n", + " grdm.get_select_storage_title_locator(page, addon_storage_name),\n", + " f'copy/storage:{addon_storage_name}')\n", " await drag_and_drop_with_alt(page, src, dst)\n", " b_intra['copy'] = {'notified': await wait_file_op_notified(page, 'コピー')}\n", " await asyncio.sleep(3)\n", @@ -3091,9 +3156,11 @@ " and b_intra.get('copy', {}).get('wb')\n", " and 'error' not in b_intra.get('moved', {})\n", " and 'error' not in b_intra.get('copied_back', {}))\n", + " _dups = {k: v for k, v in b_intra.get('row_counts', {}).items() if v != 1}\n", " b_record('S-10b-6', bool(_ok),\n", " f'move POST {len(b_intra.get(\"move\", {}).get(\"wb\", []))} 本 / '\n", - " f'copy POST {len(b_intra.get(\"copy\", {}).get(\"wb\", []))} 本')" + " f'copy POST {len(b_intra.get(\"copy\", {}).get(\"wb\", []))} 本'\n", + " + (f' / 同名の行が 1 件でないもの {_dups}' if _dups else ''))" ], "id": "cell-038", "outputs": [], @@ -3221,12 +3288,64 @@ " state['downloads_b_new'] = _new_down\n", " save_state(state)\n", " # _new_down のキーは WB の materialized パスなので basename で引き当てる。\n", - " _new_by_name = {os.path.basename(k.rstrip('/')) for k in _new_down}\n", + " _new_down_by_name = {os.path.basename(k.rstrip('/')): v\n", + " for k, v in _new_down.items()}\n", + " _new_by_name = set(_new_down_by_name)\n", " _not_placed = [n for n in _new_files if n not in _new_by_name]\n", - " b_record('S-10b-8', not _not_placed and _nv['version_count'] >= 2,\n", + "\n", + " # 新データの突合は**この run の投入値**で行う。控えに残っている\n", + " # sha256 / size は前の run の投入値で、置き直す前の中身とは一致しない。\n", + " # (b-3/b-4/b-5 はこれらのキーを対象外にしている。)\n", + " _new_check = {'expected': {}, 'after': {}, 'mismatch': []}\n", + " for _n, _path in sorted(_new_files.items()):\n", + " _want = {'sha256': sha256_file(_path), 'size': os.path.getsize(_path)}\n", + " _new_check['expected'][_n] = _want\n", + " _got = _new_down_by_name.get(_n)\n", + " _new_check['after'][_n] = _got\n", + " if not _got or _got.get('error') or _got.get('status') != 200:\n", + " _new_check['mismatch'].append(\n", + " {'name': _n, 'problem': 'not-downloadable', 'after': _got})\n", + " elif _got.get('sha256') != _want['sha256']:\n", + " _new_check['mismatch'].append(\n", + " {'name': _n, 'problem': 'sha256',\n", + " 'expected': _want['sha256'], 'after': _got.get('sha256')})\n", + " elif _got.get('size') != _want['size']:\n", + " _new_check['mismatch'].append(\n", + " {'name': _n, 'problem': 'size',\n", + " 'expected': _want['size'], 'after': _got.get('size')})\n", + "\n", + " # 別名の版は別プロジェクトなので、UI を増やさず S3 の実体で確かめる。\n", + " # 小さいファイルなので ETag = md5(マルチパートなら '-' が付くので飛ばす)。\n", + " _ver_want = {'size': os.path.getsize(_new_ver_path),\n", + " 'md5': hashlib.md5(open(_new_ver_path, 'rb').read()).hexdigest()}\n", + " _new_check['expected'][f_new_ver] = _ver_want\n", + " try:\n", + " _vh = s3_client(s3_region_default).head_object(\n", + " Bucket=s3_bucket_migver, Key=f_new_ver)\n", + " _ver_got = {'size': _vh['ContentLength'], 'md5': _vh['ETag'].strip('\"')}\n", + " except Exception as _e:\n", + " _ver_got = {'error': repr(_e)}\n", + " _new_check['after'][f_new_ver] = _ver_got\n", + " if _ver_got.get('error'):\n", + " _new_check['mismatch'].append(\n", + " {'name': f_new_ver, 'problem': 's3-head', 'after': _ver_got})\n", + " elif _ver_got['size'] != _ver_want['size']:\n", + " _new_check['mismatch'].append(\n", + " {'name': f_new_ver, 'problem': 'size',\n", + " 'expected': _ver_want['size'], 'after': _ver_got['size']})\n", + " elif '-' not in _ver_got['md5'] and _ver_got['md5'] != _ver_want['md5']:\n", + " _new_check['mismatch'].append(\n", + " {'name': f_new_ver, 'problem': 'md5',\n", + " 'expected': _ver_want['md5'], 'after': _ver_got['md5']})\n", + " record_json('S-10b-new-check.json', _new_check, quiet=False)\n", + "\n", + " b_record('S-10b-8',\n", + " not _not_placed and not _new_check['mismatch']\n", + " and _nv['version_count'] >= 2,\n", " f'新データ {len(_new_files)} 件中 置けなかったもの '\n", " f'{len(_not_placed)} 件 {_not_placed} / 別名の版 '\n", - " f'{_nv[\"version_count\"]} 件')" + " f'{_nv[\"version_count\"]} 件 / この run の投入値との不一致 '\n", + " f'{len(_new_check[\"mismatch\"])} 件')" ], "id": "cell-040", "outputs": [],