diff --git a/docs/cx/BENCHKIT_SPEC.md b/docs/cx/BENCHKIT_SPEC.md index 9d9d54c..86aaaf7 100644 --- a/docs/cx/BENCHKIT_SPEC.md +++ b/docs/cx/BENCHKIT_SPEC.md @@ -542,6 +542,7 @@ Benchkit は、アプリが出力する詳細 timer table や profiler から得 `timing_observations` は最初の段階では任意項目であり、存在しない result を ingest failure として扱わない。 同じ job から複数 result が出る場合は、各 observation に `result_exp` などの result scope を添えてよい。 Result JSON には小さな summary と file reference を置き、巨大な profiler report や詳細 table は artifact として保持する。 +Result sender は、`timing_observations` から参照された `results/*.json` と profiler archive を Measurement Artifacts として保存する。 将来、アプリ変更なしで詳細 timing を採取する場合は、profiler adapter が同じ `timing_observations` 経路へ summary と artifact reference を渡す。 その observation を `fom_breakdown` へ昇格するかどうかは、app / profiler ごとの mapping review によって決める。 @@ -553,6 +554,7 @@ This is an observation layer separate from `fom_breakdown`. At the initial stage, `timing_observations` is optional, and results without it are not treated as ingest failures. When one job emits multiple results, each observation may carry a result scope such as `result_exp`. Result JSON should keep a compact summary and file reference; large profiler reports or full timer tables should remain artifacts. +The result sender stores referenced `results/*.json` timing files and profiler archives as Measurement Artifacts. For app-change-free collection, profiler adapters may feed summaries and artifact references into the same `timing_observations` path. Promoting an observation into `fom_breakdown` remains a separate mapping review for the app or profiler output. diff --git a/docs/guides/add-app.md b/docs/guides/add-app.md index 8e59b1a..b159221 100644 --- a/docs/guides/add-app.md +++ b/docs/guides/add-app.md @@ -474,8 +474,8 @@ bk_emit_overlap compute_kernel,communication 0.05 >> results/result `source_info` は必須ではありませんが、Git などから source を取得する app では `bk_fetch_source` を使って `results/source_info.env` を残すことを推奨します。 section / overlap / profiler archive は、詳細分析や推定を使う場合の任意拡張です。 -### Performance Analysis データ(任意) -詳細データがある場合は `results/padata[0-9].tgz` として保存: +### Measurement Artifacts(任意) +詳細データがある場合、profiler archive は従来通り `results/padata[0-9].tgz` として保存できます: ```bash # PAデータの作成例 mkdir -p pa @@ -487,6 +487,7 @@ tar -czf ../results/padata0.tgz ./pa Fugaku 系アプリでは、アプリ側が profiler tool を内部で選び、Benchkit 共通の `bk_profiler` helper に渡す形が扱いやすいです。 `bk_profiler` は profiler ごとの raw data / postprocess report をまとめて `results/padata*.tgz` に保存し、archive 内の `bk_profiler_artifact/meta.json` に metadata を入れます。Benchkit や推定 package はこの `meta.json` を見て、tool、level、report kind を機械的に判断できます。 +`timing_observations` が `results/*.json` を参照する場合も、Result 送信時に同じ Measurement Artifacts として保存されます。 `fapp` では共通 level として次を扱います。 diff --git a/docs/guides/developer-reference.md b/docs/guides/developer-reference.md index e62cd15..bf7d96e 100644 --- a/docs/guides/developer-reference.md +++ b/docs/guides/developer-reference.md @@ -283,4 +283,5 @@ Treat missing `source_info`, `fom_breakdown`, or artifact references as follow-u Detailed timing artifacts may be recorded through `timing_observations` before they are promoted to `fom_breakdown`; do not treat every detailed timer or profiler region as an additive estimation section without an app-specific -mapping review. +mapping review. Referenced `results/*.json` timing files and profiler archives +are uploaded as Measurement Artifacts by the result sender. diff --git a/result_server/app.py b/result_server/app.py index 3d481c9..b610230 100644 --- a/result_server/app.py +++ b/result_server/app.py @@ -89,6 +89,7 @@ def _configure_result_directories(app, base_dir): dir_map = { "RECEIVED_DIR": os.path.join(base_dir, "received"), "RECEIVED_PADATA_DIR": os.path.join(base_dir, "received_padata"), + "RECEIVED_MEASUREMENT_ARTIFACTS_DIR": os.path.join(base_dir, "received_padata"), "RECEIVED_ESTIMATION_ARTIFACTS_DIR": os.path.join(base_dir, "received_estimation_artifacts"), "ESTIMATED_DIR": os.path.join(base_dir, "estimated_results"), } diff --git a/result_server/app_dev.py b/result_server/app_dev.py index c7db175..40b453d 100644 --- a/result_server/app_dev.py +++ b/result_server/app_dev.py @@ -232,6 +232,7 @@ def payload_too_large(_error): app.config["RECEIVED_DIR"] = received_dir app.config["RECEIVED_PADATA_DIR"] = received_padata_dir + app.config["RECEIVED_MEASUREMENT_ARTIFACTS_DIR"] = received_padata_dir app.config["RECEIVED_ESTIMATION_ARTIFACTS_DIR"] = received_estimation_artifacts_dir app.config["ESTIMATED_DIR"] = estimated_dir app.config["EXECUTION_PROFILE_DB_PATH"] = os.environ.get( diff --git a/result_server/routes/api.py b/result_server/routes/api.py index fb56003..a7e1fdd 100644 --- a/result_server/routes/api.py +++ b/result_server/routes/api.py @@ -21,6 +21,9 @@ api_bp = Blueprint("api", __name__) _TIMESTAMP_RE = re.compile(r"^\d{8}_\d{6}$") +_MEASUREMENT_ARTIFACT_BASENAME_RE = re.compile( + r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz|json)" +) DEFAULT_MAX_ARCHIVE_MEMBER_SIZE = 1024 * 1024 * 1024 DEFAULT_MAX_ARCHIVE_TOTAL_EXTRACTED_SIZE = 1024 * 1024 * 1024 DEFAULT_MAX_ARCHIVE_MEMBER_COUNT = 4096 @@ -211,8 +214,12 @@ def _safe_basename(name): return name -def _normalize_padata_artifact_slug(value): - """Return a filename-safe padata artifact slug, or None for legacy uploads.""" +def _normalize_measurement_artifact_basename( + value, + *, + error_message="Invalid measurement artifact path", +): + """Return a filename-safe results/ artifact basename, or None.""" if value is None: return None @@ -226,16 +233,45 @@ def _normalize_padata_artifact_slug(value): or "/../" in artifact_path or artifact_path.endswith("/..") ): - abort(400, description="Invalid padata artifact path") + abort(400, description=error_message) if not artifact_path.startswith("results/"): - abort(400, description="Invalid padata artifact path") + abort(400, description=error_message) basename = os.path.basename(artifact_path) - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz)", basename): - abort(400, description="Invalid padata artifact path") - if basename.endswith(".tar.gz"): - return basename[:-7] - return basename[:-4] + if not _MEASUREMENT_ARTIFACT_BASENAME_RE.fullmatch(basename): + abort(400, description=error_message) + return basename + + +def _is_profile_archive_basename(basename): + return isinstance(basename, str) and ( + basename.endswith(".tgz") or basename.endswith(".tar.gz") + ) + + +def _copy_uploaded_file(uploaded_file, save_path): + """Write an uploaded file atomically.""" + tmp_path = save_path + ".tmp" + with open(tmp_path, "wb") as f: + shutil.copyfileobj(uploaded_file.stream, f, length=1024 * 1024) + f.flush() + os.fsync(f.fileno()) + os.rename(tmp_path, save_path) + + +def _measurement_artifact_filename(timestamp, uuid_str, artifact_basename): + if artifact_basename is None: + return _safe_basename(f"padata_{timestamp}_{uuid_str}.tgz") + if _is_profile_archive_basename(artifact_basename): + artifact_slug = ( + artifact_basename[:-7] + if artifact_basename.endswith(".tar.gz") + else artifact_basename[:-4] + ) + return _safe_basename(f"padata_{timestamp}_{uuid_str}_{artifact_slug}.tgz") + return _safe_basename( + f"measurement_artifact_{timestamp}_{uuid_str}_{artifact_basename}" + ) def _load_json_by_uuid(directory, field_path, uuid_value): @@ -434,10 +470,11 @@ def ingest_estimate(): return _saved_json_response(saved), 200 +@api_bp.route("/api/ingest/measurement-artifact", methods=["POST"]) @api_bp.route("/api/ingest/padata", methods=["POST"]) @rate_limited(max_per_minute=120, key_fn=_api_rate_key, scope="api_ingest") -def ingest_padata(): - """Receive and store a PA Data archive.""" +def ingest_measurement_artifact(): + """Receive and store a measurement artifact.""" runner_id = require_api_key() uuid_str = request.form.get("id") @@ -452,12 +489,23 @@ def ingest_padata(): if not uploaded_file: abort(400, description="No file uploaded") - received_dir = current_app.config["RECEIVED_PADATA_DIR"] - artifact_slug = _normalize_padata_artifact_slug(request.form.get("artifact_path")) + received_dir = current_app.config.get( + "RECEIVED_MEASUREMENT_ARTIFACTS_DIR", + current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"]), + ) + artifact_basename = _normalize_measurement_artifact_basename( + request.form.get("artifact_path") + ) + if artifact_basename is None and not _is_profile_archive_basename( + uploaded_file.filename or "" + ): + abort(400, description="Missing measurement artifact path") - if artifact_slug: - filename = _safe_basename(f"padata_{timestamp}_{uuid_str}_{artifact_slug}.tgz") - matched_files = [filename] if os.path.exists(os.path.join(received_dir, filename)) else [] + if artifact_basename: + filename = _measurement_artifact_filename(timestamp, uuid_str, artifact_basename) + matched_files = ( + [filename] if os.path.exists(os.path.join(received_dir, filename)) else [] + ) else: legacy_pattern = re.compile(rf"^padata_\d{{8}}_\d{{6}}_{re.escape(uuid_str)}\.tgz$") matched_files = [ @@ -471,16 +519,11 @@ def ingest_padata(): shutil.move(old_file_path, backup_path) save_path = old_file_path else: - if not artifact_slug: - filename = _safe_basename(f"padata_{timestamp}_{uuid_str}.tgz") + if not artifact_basename: + filename = _measurement_artifact_filename(timestamp, uuid_str, None) save_path = os.path.join(received_dir, filename) - tmp_path = save_path + ".tmp" - with open(tmp_path, "wb") as f: - shutil.copyfileobj(uploaded_file.stream, f, length=1024 * 1024) - f.flush() - os.fsync(f.fileno()) - os.rename(tmp_path, save_path) + _copy_uploaded_file(uploaded_file, save_path) print(f"Saved: {save_path}", flush=True) response = { @@ -496,7 +539,11 @@ def ingest_padata(): actor=runner_id, target=response["file"], result="success", - details={"ingest_type": "padata", "id": uuid_str, "replaced": response["replaced"]}, + details={ + "ingest_type": "measurement_artifact", + "id": uuid_str, + "replaced": response["replaced"], + }, ) return response, 200 diff --git a/result_server/routes/results_detail_routes.py b/result_server/routes/results_detail_routes.py index bbce3a9..805f737 100644 --- a/result_server/routes/results_detail_routes.py +++ b/result_server/routes/results_detail_routes.py @@ -42,6 +42,15 @@ PADATA_ARTIFACT_BASENAME_RE = re.compile( r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz)" ) +MEASUREMENT_ARTIFACT_BASENAME_RE = re.compile( + r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz|json)" +) +MEASUREMENT_ARTIFACT_FILENAME_RE = re.compile( + r"^measurement_artifact_\d{8}_\d{6}_" + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_" + r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz|json)$", + re.IGNORECASE, +) def register_results_detail_routes(results_bp): @@ -79,15 +88,30 @@ def result_detail(filename): not_found_message="Result file not found", ) quality = summarize_result_quality(result) - padata_dir = current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"]) - padata_filenames = _list_result_padata_filenames(result, padata_dir) if is_public_surface else [ - name for name in os.listdir(padata_dir) if name.endswith(".tgz") - ] + artifact_dir = current_app.config.get( + "RECEIVED_MEASUREMENT_ARTIFACTS_DIR", + current_app.config.get( + "RECEIVED_PADATA_DIR", + current_app.config["RECEIVED_DIR"], + ), + ) + measurement_artifact_filenames = ( + _list_result_measurement_artifact_filenames( + result, + artifact_dir, + include_timing=False, + ) + if is_public_surface + else [ + name for name in os.listdir(artifact_dir) + if _is_measurement_artifact_filename(name) + ] + ) detail_context = build_result_detail_context( result, quality, load_trigger_run_lookup(current_app.config.get("EXECUTION_PROFILE_DB_PATH")), - padata_filenames, + measurement_artifact_filenames, public_surface=is_public_surface, ) public_result = not get_file_confidential_tags(filename, current_app.config["RECEIVED_DIR"]) @@ -134,7 +158,13 @@ def result_evidence_packet(filename): not_found_message="Result file not found", ) quality = summarize_result_quality(result) - padata_dir = current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"]) + padata_dir = current_app.config.get( + "RECEIVED_MEASUREMENT_ARTIFACTS_DIR", + current_app.config.get( + "RECEIVED_PADATA_DIR", + current_app.config["RECEIVED_DIR"], + ), + ) padata_filenames = [name for name in os.listdir(padata_dir) if name.endswith(".tgz")] padata_urls = { name: url_for("results.show_result", filename=name) @@ -272,20 +302,32 @@ def show_result(filename): return serve_public_padata_file( filename, current_app.config["RECEIVED_DIR"], - current_app.config["RECEIVED_PADATA_DIR"], + current_app.config.get( + "RECEIVED_MEASUREMENT_ARTIFACTS_DIR", + current_app.config["RECEIVED_PADATA_DIR"], + ), ) abort(404) - if filename.endswith(".tgz"): + if _is_measurement_artifact_filename(filename): return serve_permitted_result_file( filename, current_app.config["RECEIVED_DIR"], - current_app.config["RECEIVED_PADATA_DIR"], + current_app.config.get( + "RECEIVED_MEASUREMENT_ARTIFACTS_DIR", + current_app.config["RECEIVED_PADATA_DIR"], + ), ) return serve_permitted_result_file(filename, current_app.config["RECEIVED_DIR"]) def _build_public_reuse_manifest_for_route(result, filename): - padata_dir = current_app.config.get("RECEIVED_PADATA_DIR", current_app.config["RECEIVED_DIR"]) + padata_dir = current_app.config.get( + "RECEIVED_MEASUREMENT_ARTIFACTS_DIR", + current_app.config.get( + "RECEIVED_PADATA_DIR", + current_app.config["RECEIVED_DIR"], + ), + ) padata_filenames = _list_result_padata_filenames(result, padata_dir) padata_urls = { name: url_for("results.show_result", filename=name) @@ -323,6 +365,31 @@ def _list_result_padata_filenames(result, padata_dir): return filenames +def _list_result_measurement_artifact_filenames(result, artifact_dir, *, include_timing=True): + result_uuid = _clean_result_value(result.get("_server_uuid")) + timestamp = _clean_result_value(result.get("_server_timestamp")) + if not result_uuid or not timestamp: + return [] + + filenames = [] + seen = set() + for filename in _list_result_padata_filenames(result, artifact_dir): + seen.add(filename) + filenames.append(filename) + + if not include_timing: + return filenames + + for artifact_path in _iter_result_timing_artifact_paths(result): + filename = _measurement_artifact_filename(timestamp, result_uuid, artifact_path) + if not filename or filename in seen: + continue + seen.add(filename) + if os.path.isfile(os.path.join(artifact_dir, filename)): + filenames.append(filename) + return filenames + + def _iter_result_padata_artifact_paths(result): breakdown = result.get("fom_breakdown") if not isinstance(breakdown, dict): @@ -339,6 +406,21 @@ def _iter_result_padata_artifact_paths(result): yield path +def _iter_result_timing_artifact_paths(result): + timing_observations = result.get("timing_observations") + if not isinstance(timing_observations, dict): + return + for observation in timing_observations.get("observations") or []: + if not isinstance(observation, dict): + continue + artifact = observation.get("artifact") + if not isinstance(artifact, dict) or artifact.get("type") != "file_reference": + continue + path = _clean_result_value(artifact.get("path")) + if path: + yield path + + def _padata_artifact_slug(artifact_path): if not isinstance(artifact_path, str) or not artifact_path.startswith("results/"): return "" @@ -348,5 +430,27 @@ def _padata_artifact_slug(artifact_path): return basename[:-7] if basename.endswith(".tar.gz") else basename[:-4] +def _measurement_artifact_filename(timestamp, result_uuid, artifact_path): + basename = _measurement_artifact_basename(artifact_path) + if not basename: + return "" + return f"measurement_artifact_{timestamp}_{result_uuid}_{basename}" + + +def _measurement_artifact_basename(artifact_path): + if not isinstance(artifact_path, str) or not artifact_path.startswith("results/"): + return "" + basename = os.path.basename(artifact_path) + if not MEASUREMENT_ARTIFACT_BASENAME_RE.fullmatch(basename): + return "" + return basename + + +def _is_measurement_artifact_filename(filename): + return filename.endswith(".tgz") or bool( + MEASUREMENT_ARTIFACT_FILENAME_RE.fullmatch(filename) + ) + + def _clean_result_value(value): return str(value or "").strip() diff --git a/result_server/routes/results_list_routes.py b/result_server/routes/results_list_routes.py index b8f7a88..19a6a7c 100644 --- a/result_server/routes/results_list_routes.py +++ b/result_server/routes/results_list_routes.py @@ -18,7 +18,10 @@ def _render_results_list(public_only, template_name, redirect_endpoint): public_surface = public_only and current_app.config.get("PUBLIC_PORTAL_MODE", False) received_dir = current_app.config["RECEIVED_DIR"] - received_padata_dir = current_app.config.get("RECEIVED_PADATA_DIR", received_dir) + received_padata_dir = current_app.config.get( + "RECEIVED_MEASUREMENT_ARTIFACTS_DIR", + current_app.config.get("RECEIVED_PADATA_DIR", received_dir), + ) systems_info = get_all_systems_info() load_kwargs = dict( diff --git a/result_server/templates/result_detail.html b/result_server/templates/result_detail.html index d1ee5ed..396666a 100644 --- a/result_server/templates/result_detail.html +++ b/result_server/templates/result_detail.html @@ -73,18 +73,19 @@ {{ render_titled_key_value_table("PA Data Summary", profile_rows, "meta-table") }} {% endif %} -{% if profile_artifact_rows %} +{% if measurement_artifact_rows %}
-

PA Data Archives

+

Measurement Artifacts

- + - {% for row in profile_artifact_rows %} + {% for row in measurement_artifact_rows %} - + +
SectionArtifactArchive
KindSourceArtifactStored File
{{ row.section }}{{ row.kind }}{{ row.source }} {{ row.artifact_path }} {% if row.link %} diff --git a/result_server/test_support.py b/result_server/test_support.py index 0e5cf8e..72202bb 100644 --- a/result_server/test_support.py +++ b/result_server/test_support.py @@ -193,6 +193,7 @@ def build_results_route_app( if received_padata_dir is not None: app.config["RECEIVED_PADATA_DIR"] = received_padata_dir + app.config["RECEIVED_MEASUREMENT_ARTIFACTS_DIR"] = received_padata_dir from routes.results import results_bp @@ -212,6 +213,7 @@ def build_api_route_app( app = Flask(__name__) app.config["RECEIVED_DIR"] = received_dir app.config["RECEIVED_PADATA_DIR"] = received_padata_dir + app.config["RECEIVED_MEASUREMENT_ARTIFACTS_DIR"] = received_padata_dir app.config["RECEIVED_ESTIMATION_ARTIFACTS_DIR"] = received_estimation_artifacts_dir app.config["ESTIMATED_DIR"] = estimated_dir if execution_profile_db_path is not None: diff --git a/result_server/tests/test_api_routes.py b/result_server/tests/test_api_routes.py index 2fb758f..b7e4c12 100644 --- a/result_server/tests/test_api_routes.py +++ b/result_server/tests/test_api_routes.py @@ -428,6 +428,90 @@ def test_missing_api_key_returns_401(self, client): assert resp.status_code == 401 +# ============================================================ +# /api/ingest/measurement-artifact +# ============================================================ + +class TestIngestMeasurementArtifact: + def test_upload_timing_json_file(self, client, tmp_dirs): + """Timing JSON artifacts should be stored as measurement artifacts.""" + data = { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "20250101_120000", + "artifact_path": "results/qws_timing_CASE0.json", + "file": (io.BytesIO(b'{"timers": []}'), "qws_timing_CASE0.json"), + } + resp = client.post( + "/api/ingest/measurement-artifact", + data=data, + headers={"X-API-Key": API_KEY}, + content_type="multipart/form-data", + ) + + assert resp.status_code == 200 + body = resp.get_json() + assert body["status"] == "uploaded" + assert body["file"] == ( + "measurement_artifact_20250101_120000_" + "12345678-1234-1234-1234-123456789abc_qws_timing_CASE0.json" + ) + assert sorted(os.listdir(tmp_dirs[1])) == [body["file"]] + + def test_upload_profile_tgz_uses_profile_archive_name(self, client, tmp_dirs): + """Profile archives remain addressable by the existing padata filename form.""" + data = { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "20250101_120000", + "artifact_path": "results/padata_pairlist.tgz", + "file": (io.BytesIO(b"fake tgz content"), "padata_pairlist.tgz"), + } + resp = client.post( + "/api/ingest/measurement-artifact", + data=data, + headers={"X-API-Key": API_KEY}, + content_type="multipart/form-data", + ) + + assert resp.status_code == 200 + body = resp.get_json() + assert body["file"] == ( + "padata_20250101_120000_" + "12345678-1234-1234-1234-123456789abc_padata_pairlist.tgz" + ) + assert sorted(os.listdir(tmp_dirs[1])) == [body["file"]] + + @pytest.mark.parametrize("artifact_path", [ + "../qws_timing.json", + "results/../qws_timing.json", + "/tmp/qws_timing.json", + "results/bad name.json", + "artifacts/qws_timing.json", + "results/qws_timing.txt", + ]) + def test_rejects_invalid_measurement_artifact_path(self, client, artifact_path): + data = { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "20250101_120000", + "artifact_path": artifact_path, + "file": (io.BytesIO(b"data"), "test.json"), + } + resp = client.post( + "/api/ingest/measurement-artifact", + data=data, + headers={"X-API-Key": API_KEY}, + content_type="multipart/form-data", + ) + assert resp.status_code == 400 + + def test_missing_api_key_returns_401(self, client): + resp = client.post( + "/api/ingest/measurement-artifact", + data={"id": "x", "timestamp": "t"}, + content_type="multipart/form-data", + ) + assert resp.status_code == 401 + + # ============================================================ # /api/ingest/padata # ============================================================ diff --git a/result_server/tests/test_result_detail_template.py b/result_server/tests/test_result_detail_template.py index b317404..e34ceaa 100644 --- a/result_server/tests/test_result_detail_template.py +++ b/result_server/tests/test_result_detail_template.py @@ -168,7 +168,7 @@ def _render_result_detail(result, quality, padata_filenames=None, *, public_surf detail_context = build_result_detail_context( result, quality, - padata_filenames=padata_filenames, + measurement_artifact_filenames=padata_filenames, public_surface=public_surface, ) return render_template("result_detail.html", result=result, quality=quality, **detail_context) @@ -312,7 +312,9 @@ def test_section_padata_archives_are_linked(self, app): with app.test_request_context(): html = _render_result_detail(result, FULL_QUALITY, [filename]) - assert "PA Data Archives" in html + assert "Measurement Artifacts" in html + assert "Profile archive" in html + assert "Section: pairlist" in html assert "pairlist" in html assert "results/padata_k003_void_kern_build_pairlist.tgz" in html assert f'href="/results/{filename}"' in html @@ -351,9 +353,51 @@ def test_public_surface_keeps_padata_archive_links(self, app): public_surface=True, ) - assert "PA Data Archives" in html + assert "Measurement Artifacts" in html assert f'href="/results/{filename}"' in html + def test_timing_observation_artifact_is_linked_on_console_surface(self, app): + result = { + **FULL_RESULT, + "_server_uuid": "12345678-1234-1234-1234-123456789abc", + "_server_timestamp": "20260819_161329", + } + filename = ( + "measurement_artifact_20260819_161329_" + "12345678-1234-1234-1234-123456789abc_qws_timing_CASE0.json" + ) + + with app.test_request_context(): + html = _render_result_detail(result, FULL_QUALITY, [filename]) + + assert "Measurement Artifacts" in html + assert "Timing observation" in html + assert "qws-case0-timers" in html + assert "results/qws_timing_CASE0.json" in html + assert f'href="/results/{filename}"' in html + + def test_timing_observation_artifact_is_hidden_on_public_surface(self, app): + result = { + **FULL_RESULT, + "_server_uuid": "12345678-1234-1234-1234-123456789abc", + "_server_timestamp": "20260819_161329", + } + filename = ( + "measurement_artifact_20260819_161329_" + "12345678-1234-1234-1234-123456789abc_qws_timing_CASE0.json" + ) + + with app.test_request_context(): + html = _render_result_detail( + result, + FULL_QUALITY, + [filename], + public_surface=True, + ) + + assert "Timing observation" not in html + assert filename not in html + def test_vector_data_table(self, app): with app.test_request_context(): html = _render_result_detail(FULL_RESULT, FULL_QUALITY) diff --git a/result_server/tests/test_result_padata_route.py b/result_server/tests/test_result_padata_route.py index 23e2c4d..2d9d101 100644 --- a/result_server/tests/test_result_padata_route.py +++ b/result_server/tests/test_result_padata_route.py @@ -52,6 +52,23 @@ def test_results_route_serves_padata_from_received_padata_dir(client, tmp_dirs): assert resp.data == b"fake tgz content" +def test_results_route_serves_measurement_json_from_artifact_dir(client, tmp_dirs): + received, received_padata = tmp_dirs + uid = "12345678-1234-1234-1234-123456789abc" + json_name = f"result_20250101_120000_{uid}.json" + artifact_name = f"measurement_artifact_20250101_120000_{uid}_qws_timing_CASE0.json" + + with open(os.path.join(received, json_name), "w", encoding="utf-8") as f: + json.dump({"code": "qws", "system": "DemoSystem", "FOM": 1.0}, f) + + with open(os.path.join(received_padata, artifact_name), "w", encoding="utf-8") as f: + json.dump({"timers": []}, f) + + resp = client.get(f"/results/{artifact_name}") + assert resp.status_code == 200 + assert resp.get_json() == {"timers": []} + + def test_public_portal_mode_serves_anonymous_public_padata(client, app, tmp_dirs): received, received_padata = tmp_dirs app.config["PUBLIC_PORTAL_MODE"] = True @@ -70,6 +87,20 @@ def test_public_portal_mode_serves_anonymous_public_padata(client, app, tmp_dirs assert resp.data == b"fake tgz content" +def test_public_portal_mode_hides_measurement_json(client, app, tmp_dirs): + received, received_padata = tmp_dirs + app.config["PUBLIC_PORTAL_MODE"] = True + uid = "12345678-1234-1234-1234-123456789abc" + artifact_name = f"measurement_artifact_20250101_120000_{uid}_qws_timing_CASE0.json" + + with open(os.path.join(received, "result0.json"), "w", encoding="utf-8") as f: + json.dump({"code": "qws", "system": "DemoSystem", "FOM": 1.0, "_server_uuid": uid}, f) + with open(os.path.join(received_padata, artifact_name), "w", encoding="utf-8") as f: + json.dump({"timers": []}, f) + + assert client.get(f"/results/{artifact_name}").status_code == 404 + + def test_public_portal_mode_serves_authenticated_public_padata(client, app, tmp_dirs): received, received_padata = tmp_dirs app.config["PUBLIC_PORTAL_MODE"] = True @@ -147,3 +178,27 @@ def test_results_route_blocks_confidential_padata_matched_by_server_uuid(client, resp = client.get(f"/results/{tgz_name}") assert resp.status_code == 403 + + +def test_results_route_blocks_confidential_measurement_json(client, tmp_dirs): + received, received_padata = tmp_dirs + uid = "12345678-1234-1234-1234-123456789abc" + artifact_name = f"measurement_artifact_20250101_120000_{uid}_qws_timing_CASE0.json" + + with open(os.path.join(received, "result0.json"), "w", encoding="utf-8") as f: + json.dump( + { + "code": "qws", + "system": "DemoSystem", + "FOM": 1.0, + "_server_uuid": uid, + "confidential": ["dev"], + }, + f, + ) + + with open(os.path.join(received_padata, artifact_name), "w", encoding="utf-8") as f: + json.dump({"timers": []}, f) + + resp = client.get(f"/results/{artifact_name}") + assert resp.status_code == 403 diff --git a/result_server/utils/evidence_packet.py b/result_server/utils/evidence_packet.py index d2c73d6..20f5177 100644 --- a/result_server/utils/evidence_packet.py +++ b/result_server/utils/evidence_packet.py @@ -118,7 +118,7 @@ def build_result_evidence_packet( packet.table(_profile_rows(result.get("profile_data"))) artifact_rows = _profile_artifact_rows(result, padata_filenames or [], padata_url_by_filename or {}) if artifact_rows: - packet.heading(3, "PA Data Archives") + packet.heading(3, "Measurement Artifacts") packet.table(artifact_rows) build_cache_rows = _build_cache_rows(result.get("build_cache")) diff --git a/result_server/utils/result_detail_view.py b/result_server/utils/result_detail_view.py index 3d497af..ddb85f4 100644 --- a/result_server/utils/result_detail_view.py +++ b/result_server/utils/result_detail_view.py @@ -80,6 +80,10 @@ }, } +MEASUREMENT_ARTIFACT_BASENAME_RE = re.compile( + r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz|json)" +) + def build_cache_host_environment_help(context="matched"): return HOST_ENVIRONMENT_FINGERPRINT_HELP.get( @@ -97,7 +101,7 @@ def build_result_detail_context( result, quality, trigger_runs_by_pipeline=None, - padata_filenames=None, + measurement_artifact_filenames=None, *, public_surface=False, ): @@ -110,7 +114,11 @@ def build_result_detail_context( "meta_rows": _build_meta_rows(result, trigger_runs_by_pipeline, public_surface=public_surface), "profile_rows": _build_profile_rows(profile_data), "quality_rows": [] if public_surface else _build_quality_rows(quality), - "profile_artifact_rows": _build_profile_artifact_rows(result, padata_filenames or []), + "measurement_artifact_rows": _build_measurement_artifact_rows( + result, + measurement_artifact_filenames or [], + include_timing=not public_surface, + ), "timing_observation_rows": ( [] if public_surface else _build_timing_observation_rows(result.get("timing_observations")) ), @@ -209,45 +217,124 @@ def _build_tool_specific_detail(profile_data): return mapping.get(level, "fapp tool-specific event set") -def _build_profile_artifact_rows(result, padata_filenames): +def _build_measurement_artifact_rows( + result, + measurement_artifact_filenames, + *, + include_timing=True, +): result_uuid = result.get("_server_uuid") timestamp = result.get("_server_timestamp") if not result_uuid or not timestamp: return [] + uploaded = set(measurement_artifact_filenames) + rows = _build_profile_measurement_artifact_rows(result, timestamp, result_uuid, uploaded) + if include_timing: + rows.extend( + _build_timing_measurement_artifact_rows(result, timestamp, result_uuid, uploaded) + ) + return rows + + +def _build_profile_measurement_artifact_rows(result, timestamp, result_uuid, uploaded): rows = [] - for section in (result.get("fom_breakdown") or {}).get("sections") or []: - if not isinstance(section, dict): - continue - section_name = section.get("name") or "-" - for artifact in section.get("artifacts") or []: - if not isinstance(artifact, dict) or artifact.get("type") != "file_reference": - continue - artifact_path = artifact.get("path") or "" - artifact_slug = _padata_artifact_slug(artifact_path) - if not artifact_slug: + breakdown = result.get("fom_breakdown") + if not isinstance(breakdown, dict): + return rows + + for collection_name, source_label in (("sections", "Section"), ("overlaps", "Overlap")): + for item in breakdown.get(collection_name) or []: + if not isinstance(item, dict): continue - filename = f"padata_{timestamp}_{result_uuid}_{artifact_slug}.tgz" - rows.append({ - "section": section_name, - "artifact_path": artifact_path, - "filename": filename, - "link": url_for("results.show_result", filename=filename) if filename in padata_filenames else None, - }) + item_name = item.get("name") or "-" + for artifact in item.get("artifacts") or []: + if not isinstance(artifact, dict) or artifact.get("type") != "file_reference": + continue + artifact_path = artifact.get("path") or "" + candidates = _profile_artifact_filenames(timestamp, result_uuid, artifact_path) + if not candidates: + continue + filename = _choose_uploaded_filename(candidates, uploaded) + rows.append({ + "kind": "Profile archive", + "source": f"{source_label}: {item_name}", + "artifact_path": artifact_path, + "filename": filename, + "link": ( + url_for("results.show_result", filename=filename) + if filename in uploaded + else None + ), + }) + return rows + + +def _build_timing_measurement_artifact_rows(result, timestamp, result_uuid, uploaded): + timing_observations = result.get("timing_observations") + if not isinstance(timing_observations, dict): + return [] + + observations = timing_observations.get("observations") + if not isinstance(observations, list): + return [] + + rows = [] + for index, observation in enumerate(observations, start=1): + if not isinstance(observation, dict): + continue + artifact = observation.get("artifact") + artifact = artifact if isinstance(artifact, dict) else {} + if artifact.get("type") != "file_reference": + continue + artifact_path = str(artifact.get("path") or "").strip() + basename = _measurement_artifact_basename(artifact_path) + if not basename: + continue + filename = f"measurement_artifact_{timestamp}_{result_uuid}_{basename}" + label = str(observation.get("id") or f"Observation {index}") + rows.append({ + "kind": "Timing observation", + "source": label, + "artifact_path": artifact_path, + "filename": filename, + "link": ( + url_for("results.show_result", filename=filename) + if filename in uploaded + else None + ), + }) return rows -def _padata_artifact_slug(artifact_path): +def _choose_uploaded_filename(candidates, uploaded): + for filename in candidates: + if filename in uploaded: + return filename + return candidates[0] + + +def _profile_artifact_filenames(timestamp, result_uuid, artifact_path): + basename = _measurement_artifact_basename(artifact_path) + if not basename or not (basename.endswith(".tgz") or basename.endswith(".tar.gz")): + return [] + + artifact_slug = basename[:-7] if basename.endswith(".tar.gz") else basename[:-4] + return [ + f"padata_{timestamp}_{result_uuid}_{artifact_slug}.tgz", + f"measurement_artifact_{timestamp}_{result_uuid}_{basename}", + ] + + +def _measurement_artifact_basename(artifact_path): if not isinstance(artifact_path, str): return "" if not artifact_path.startswith("results/"): return "" basename = os.path.basename(artifact_path) - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz)", basename): + if not MEASUREMENT_ARTIFACT_BASENAME_RE.fullmatch(basename): return "" - if basename.endswith(".tar.gz"): - return basename[:-7] - return basename[:-4] + return basename def _build_quality_rows(quality): diff --git a/result_server/utils/result_file.py b/result_server/utils/result_file.py index 6148b8c..0f08708 100644 --- a/result_server/utils/result_file.py +++ b/result_server/utils/result_file.py @@ -20,6 +20,12 @@ r"(?:_[A-Za-z0-9][A-Za-z0-9_.-]{0,127})?\.tgz$", re.IGNORECASE, ) +MEASUREMENT_ARTIFACT_FILENAME_RE = re.compile( + r"^measurement_artifact_\d{8}_\d{6}_" + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_" + r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\.(?:tgz|tar\.gz|json)$", + re.IGNORECASE, +) def load_result_file(filename: str, save_dir: str): @@ -67,11 +73,13 @@ def resolve_safe_child_path( def get_file_confidential_tags(filename: str, save_dir: str): - """Return confidential tags from a JSON file or its matching PA archive.""" - if filename.endswith(".json"): + """Return confidential tags from a JSON file or its matching artifact result.""" + if filename.endswith(".json") and not MEASUREMENT_ARTIFACT_FILENAME_RE.fullmatch( + filename + ): return _read_confidential_from_json(filename, save_dir) - # For TGZ files, find the matching JSON by UUID and reuse its tags. + # For measurement artifacts, find the matching Result JSON by UUID and reuse its tags. tags = [] for _json_filename, data in _matching_result_json_for_padata(filename, save_dir): tags.extend(_extract_confidential_tags(data)) diff --git a/scripts/result_server/send_results.sh b/scripts/result_server/send_results.sh index f027636..57dbedd 100644 --- a/scripts/result_server/send_results.sh +++ b/scripts/result_server/send_results.sh @@ -119,11 +119,20 @@ log_result_summary() { fi } -is_safe_local_padata_path() { +is_profile_archive_path() { local artifact_path="$1" case "$artifact_path" in - results/*.tgz|results/*.tar.gz) ;; + *.tgz|*.tar.gz) return 0 ;; + *) return 1 ;; + esac +} + +is_safe_local_measurement_artifact_path() { + local artifact_path="$1" + + case "$artifact_path" in + results/*.tgz|results/*.tar.gz|results/*.json) ;; *) return 1 ;; esac @@ -134,64 +143,65 @@ is_safe_local_padata_path() { [[ -f "$artifact_path" ]] } -collect_padata_archives_for_result() { +collect_measurement_artifacts_for_result() { local json_file="$1" local legacy_tgz_file="$2" - declare -A seen_archives=() + declare -A seen_artifacts=() if [[ -f "$legacy_tgz_file" ]]; then - seen_archives["$legacy_tgz_file"]=1 + seen_artifacts["$legacy_tgz_file"]=1 printf '%s\t%s\n' "$legacy_tgz_file" "" fi while IFS= read -r artifact_path; do [[ -n "$artifact_path" ]] || continue - if is_safe_local_padata_path "$artifact_path" && [[ -z "${seen_archives[$artifact_path]:-}" ]]; then - seen_archives["$artifact_path"]=1 + if is_safe_local_measurement_artifact_path "$artifact_path" && [[ -z "${seen_artifacts[$artifact_path]:-}" ]]; then + seen_artifacts["$artifact_path"]=1 printf '%s\t%s\n' "$artifact_path" "$artifact_path" fi done < <(jq -r ' - (.fom_breakdown.sections // [])[]? - | (.artifacts // [])[]? - | select(.type == "file_reference") - | .path // empty + def file_reference_paths: + ((.fom_breakdown.sections // [])[]? | (.artifacts // [])[]? | select(.type == "file_reference") | .path // empty), + ((.fom_breakdown.overlaps // [])[]? | (.artifacts // [])[]? | select(.type == "file_reference") | .path // empty), + ((.timing_observations.observations // [])[]? | .artifact? | select(.type == "file_reference") | .path // empty); + file_reference_paths ' "$json_file" 2>/dev/null || true) } -upload_padata_archive() { - local tgz_file="$1" +upload_measurement_artifact() { + local artifact_file="$1" local uuid="$2" local timestamp="$3" local artifact_path="${4:-}" local response - echo "Uploading $tgz_file with UUID $uuid" + echo "Uploading measurement artifact $artifact_file with UUID $uuid" local curl_auth_args=() local curl_form_args=( -F "id=${uuid}" -F "timestamp=${timestamp}" - -F "file=@${tgz_file}" + -F "file=@${artifact_file}" ) if [[ -n "$artifact_path" ]]; then curl_form_args+=(-F "artifact_path=${artifact_path}") fi bk_result_server_set_curl_args - if response=$(curl --fail -sS "${curl_auth_args[@]}" -X POST "${RESULT_SERVER}/api/ingest/padata" \ + if response=$(curl --fail -sS "${curl_auth_args[@]}" -X POST "${RESULT_SERVER}/api/ingest/measurement-artifact" \ "${curl_form_args[@]}" 2>&1); then if [[ -n "$response" ]]; then echo "$response" fi - echo "Uploaded $tgz_file" + echo "Uploaded measurement artifact $artifact_file" return 0 fi if printf '%s\n' "$response" | grep -q '413'; then - echo "WARNING: Skipping padata upload because the server rejected ${tgz_file} as too large (HTTP 413)." >&2 - echo "WARNING: Result JSON was already ingested; the padata archive remains available as a GitLab artifact for downstream jobs." >&2 + echo "WARNING: Skipping measurement artifact upload because the server rejected ${artifact_file} as too large (HTTP 413)." >&2 + echo "WARNING: Result JSON was already ingested; the measurement artifact remains available as a CI artifact for downstream jobs." >&2 return 0 fi - echo "ERROR: Failed to upload ${tgz_file}" >&2 + echo "ERROR: Failed to upload measurement artifact ${artifact_file}" >&2 echo "$response" >&2 return 1 } @@ -212,15 +222,17 @@ for json_file in results/result*.json; do echo tgz_file "$tgz_file" - padata_archive_paths=() - padata_archive_specs=() - while IFS=$'\t' read -r archive_path artifact_path; do - [[ -n "$archive_path" ]] || continue - padata_archive_paths+=("$archive_path") - padata_archive_specs+=("${archive_path}"$'\t'"${artifact_path}") - done < <(collect_padata_archives_for_result "$json_file" "$tgz_file") + profile_archive_paths=() + measurement_artifact_specs=() + while IFS=$'\t' read -r artifact_file artifact_path; do + [[ -n "$artifact_file" ]] || continue + if is_profile_archive_path "$artifact_file"; then + profile_archive_paths+=("$artifact_file") + fi + measurement_artifact_specs+=("${artifact_file}"$'\t'"${artifact_path}") + done < <(collect_measurement_artifacts_for_result "$json_file" "$tgz_file") - profile_data_summary=$(build_profile_data_summary_for_archives "${padata_archive_paths[@]}") + profile_data_summary=$(build_profile_data_summary_for_archives "${profile_archive_paths[@]}") if [[ -n "$profile_data_summary" ]]; then tmp_file="${json_file}.tmp" jq --argjson profile_data "$profile_data_summary" \ @@ -281,16 +293,16 @@ for json_file in results/result*.json; do echo "Updated result metadata manifest: $meta_file" - # Upload matching profiler archives. Legacy resultN.json/padataN.tgz pairs are - # kept, and section artifact archives are sent with artifact_path so the server - # can keep multiple archives for the same result UUID. - if [[ "${#padata_archive_specs[@]}" -gt 0 ]]; then - for archive_spec in "${padata_archive_specs[@]}"; do - IFS=$'\t' read -r archive_path artifact_path <<< "$archive_spec" - upload_padata_archive "$archive_path" "$uuid" "$timestamp" "$artifact_path" + # Upload matching measurement artifacts. Legacy resultN.json/padataN.tgz pairs + # are kept, and referenced artifacts are sent with artifact_path so the server + # can keep multiple artifacts for the same result UUID. + if [[ "${#measurement_artifact_specs[@]}" -gt 0 ]]; then + for artifact_spec in "${measurement_artifact_specs[@]}"; do + IFS=$'\t' read -r artifact_file artifact_path <<< "$artifact_spec" + upload_measurement_artifact "$artifact_file" "$uuid" "$timestamp" "$artifact_path" done else - echo "No profiler TGZ found for $json_file (expected: $tgz_file or section artifact padata archives). Skipping upload." + echo "No measurement artifacts found for $json_file (expected: $tgz_file or referenced artifacts). Skipping upload." fi done diff --git a/scripts/tests/test_send_results_profile_data.sh b/scripts/tests/test_send_results_profile_data.sh index 57ae82b..ca142ea 100644 --- a/scripts/tests/test_send_results_profile_data.sh +++ b/scripts/tests/test_send_results_profile_data.sh @@ -57,10 +57,36 @@ cat > "${TMP_DIR}/results/result0.json" <<'EOF' } ], "overlaps": [] + }, + "timing_observations": { + "schema_version": 1, + "observations": [ + { + "id": "qws-case0-timers", + "kind": "detailed-timing", + "producer": "qws", + "format": "qws_timing_observation/v1", + "result_exp": "CASE0", + "artifact": { + "type": "file_reference", + "path": "results/qws_timing_CASE0.json" + }, + "summary": { + "timer_count": 14 + } + } + ] } } EOF +cat > "${TMP_DIR}/results/qws_timing_CASE0.json" <<'EOF' +{ + "schema_version": 1, + "timers": [] +} +EOF + cat > "${TMP_DIR}/bk_profiler_artifact/meta.json" <<'EOF' { "tool": "ncu", @@ -97,9 +123,9 @@ if printf '%s\n' "$*" | grep -q '/api/ingest/result'; then printf '%s\n' '{"id":"11111111-2222-3333-4444-555555555555","timestamp":"20260413_230000"}' exit 0 fi -if printf '%s\n' "$*" | grep -q '/api/ingest/padata'; then - printf '%s\n' "$*" >> "${TMP_DIR}/padata_uploads.log" - if [ "${FAKE_PADATA_STATUS:-200}" = "413" ]; then +if printf '%s\n' "$*" | grep -q '/api/ingest/measurement-artifact'; then + printf '%s\n' "$*" >> "${TMP_DIR}/measurement_artifact_uploads.log" + if [ "${FAKE_MEASUREMENT_ARTIFACT_STATUS:-200}" = "413" ]; then echo "curl: (22) The requested URL returned error: 413" >&2 exit 22 fi @@ -214,12 +240,18 @@ import sys path, expr = sys.argv[1:3] with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) -if "fom_breakdown.sections" in expr: - for section in data.get("fom_breakdown", {}).get("sections", []): - for artifact in section.get("artifacts", []): - artifact_path = artifact.get("path") - if artifact_path and artifact_path.split("/")[-1].startswith("padata") and artifact_path.endswith(".tgz"): - print(artifact_path) +if "file_reference_paths" in expr: + breakdown = data.get("fom_breakdown", {}) + for collection_name in ("sections", "overlaps"): + for item in breakdown.get(collection_name, []): + for artifact in item.get("artifacts", []): + if artifact.get("type") == "file_reference" and artifact.get("path"): + print(artifact["path"]) + observations = data.get("timing_observations", {}).get("observations", []) + for observation in observations: + artifact = observation.get("artifact", {}) + if artifact.get("type") == "file_reference" and artifact.get("path"): + print(artifact["path"]) sys.exit(0) raise SystemExit(1) PY @@ -330,14 +362,16 @@ grep -Eq '"ncu_options":[[:space:]]*\[' "${TMP_DIR}/results/result0.json" grep -Eq '"ncu_report"' "${TMP_DIR}/results/result0.json" grep -q '"_server_uuid": "11111111-2222-3333-4444-555555555555"' "${TMP_DIR}/results/result0.json" grep -q '"result0.json"' "${TMP_DIR}/results/server_result_meta.json" -grep -q 'padata0.tgz' "${TMP_DIR}/padata_uploads.log" -grep -q 'padata_k001.tgz' "${TMP_DIR}/padata_uploads.log" -grep -q 'padata_k002.tgz' "${TMP_DIR}/padata_uploads.log" -grep -q 'padata_k003.tgz' "${TMP_DIR}/padata_uploads.log" -grep -q 'artifact_path=results/padata_k001.tgz' "${TMP_DIR}/padata_uploads.log" -grep -q 'artifact_path=results/padata_k002.tgz' "${TMP_DIR}/padata_uploads.log" -grep -q 'artifact_path=results/padata_k003.tgz' "${TMP_DIR}/padata_uploads.log" -test "$(grep -c '/api/ingest/padata' "${TMP_DIR}/padata_uploads.log")" = "4" +grep -q 'padata0.tgz' "${TMP_DIR}/measurement_artifact_uploads.log" +grep -q 'padata_k001.tgz' "${TMP_DIR}/measurement_artifact_uploads.log" +grep -q 'padata_k002.tgz' "${TMP_DIR}/measurement_artifact_uploads.log" +grep -q 'padata_k003.tgz' "${TMP_DIR}/measurement_artifact_uploads.log" +grep -q 'qws_timing_CASE0.json' "${TMP_DIR}/measurement_artifact_uploads.log" +grep -q 'artifact_path=results/padata_k001.tgz' "${TMP_DIR}/measurement_artifact_uploads.log" +grep -q 'artifact_path=results/padata_k002.tgz' "${TMP_DIR}/measurement_artifact_uploads.log" +grep -q 'artifact_path=results/padata_k003.tgz' "${TMP_DIR}/measurement_artifact_uploads.log" +grep -q 'artifact_path=results/qws_timing_CASE0.json' "${TMP_DIR}/measurement_artifact_uploads.log" +test "$(grep -c '/api/ingest/measurement-artifact' "${TMP_DIR}/measurement_artifact_uploads.log")" = "5" mkdir -p "${TMP_DIR}/case413/results" cp "${TMP_DIR}/results/result0.json" "${TMP_DIR}/case413/results/result0.json" @@ -345,12 +379,13 @@ cp "${TMP_DIR}/results/padata0.tgz" "${TMP_DIR}/case413/results/padata0.tgz" cp "${TMP_DIR}/results/padata_k001.tgz" "${TMP_DIR}/case413/results/padata_k001.tgz" cp "${TMP_DIR}/results/padata_k002.tgz" "${TMP_DIR}/case413/results/padata_k002.tgz" cp "${TMP_DIR}/results/padata_k003.tgz" "${TMP_DIR}/case413/results/padata_k003.tgz" +cp "${TMP_DIR}/results/qws_timing_CASE0.json" "${TMP_DIR}/case413/results/qws_timing_CASE0.json" -export FAKE_PADATA_STATUS=413 +export FAKE_MEASUREMENT_ARTIFACT_STATUS=413 pushd "${TMP_DIR}/case413" >/dev/null bash "${REPO_DIR}/scripts/result_server/send_results.sh" > send_results_413.log 2>&1 popd >/dev/null -unset FAKE_PADATA_STATUS +unset FAKE_MEASUREMENT_ARTIFACT_STATUS grep -q 'HTTP 413' "${TMP_DIR}/case413/send_results_413.log" grep -q 'All done.' "${TMP_DIR}/case413/send_results_413.log"