diff --git a/docs-main/overview/reference/topology.mdx b/docs-main/overview/reference/topology.mdx index a662cf72b..5dfcb979c 100644 --- a/docs-main/overview/reference/topology.mdx +++ b/docs-main/overview/reference/topology.mdx @@ -3,6 +3,8 @@ title: "Topology" description: "Canton topology management: namespaces, cryptographic keys, party-to-participant mappings, authorization chains, and topology transactions." --- +import { topologyProtoUrl } from '/snippets/generated/canton-topology-proto-link.mdx'; + {/* COPIED_START source="canton:docs-open/src/sphinx/overview/explanations/canton/topology.rst@5f96e9893" hash="05973fe4" */} # Topology management @@ -89,13 +91,7 @@ A topology transaction holds or modifies state about a certain aspect of the top Every mapping type defines how to compute the unique key based on the mapping's content. At most one topology transaction per unique key can be active at any given time. For example, the unique key of a `PartyToParticipantMapping` mapping consists of the `partyId`. This means that there can only ever be at most one active `PartyToParticipantMapping` topology transaction for a given `partyId` per Synchronizer. -For definitions of all mappings and the respective unique keys, please refer to topology.proto. - -
- -link to topology proto? \<[https://github.com/DACH-NY/canton/issues/25656](https://github.com/DACH-NY/canton/issues/25656)\> - -
+For definitions of all mappings and the respective unique keys, please refer to topology.proto. ### Serial @@ -295,13 +291,7 @@ This section goes into more detail about these aspects. Each type of topology mapping changes a certain aspect of the Synchronizer's topology state, which must be authorized by the key holders that are either impacted by or responsible for that change. In other words, only key holders can make changes to the topology state under their responsibility. For example, only the key holders of a Participant Node may change the list of packages that are vetted by that Participant Node. Similarly, only the key holders of the Synchronizer may change dynamic parameters of the Synchronizer. -For definitions of all mappings and the respective authorization rules, please refer to topology.proto. - -
- -link to topology proto? \<[https://github.com/DACH-NY/canton/issues/25656](https://github.com/DACH-NY/canton/issues/25656)\> - -
+For definitions of all mappings and the respective authorization rules, please refer to topology.proto. Some topology mappings define more than one required authorizer for certain changes. A common scenario is the hosting of a party on a Participant Node that is not owned by the same key holder as the party. This scenario was shown in the example for evolving topology mappings (see `topology-proposals-example`). On the one hand, hosting a party gives the Participant Node access to data in Daml transactions that pertains to the party. Therefore, the party's key holder must express its consent that it wishes the Participant Node to receive and process the party's Daml transactions. On the other hand, the Participant Node's key holders must consent to host the party, because hosting a party incurs network and storage cost, as well as the obligation to comply with the two-phase commit protocol for Daml transactions that involve the party. diff --git a/docs-main/snippets/generated/canton-topology-proto-link.mdx b/docs-main/snippets/generated/canton-topology-proto-link.mdx new file mode 100644 index 000000000..36b623c35 --- /dev/null +++ b/docs-main/snippets/generated/canton-topology-proto-link.mdx @@ -0,0 +1 @@ +export const topologyProtoUrl = 'https://github.com/digital-asset/canton/blob/release-line-3.5/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto'; diff --git a/package.json b/package.json index 03f9f1a4d..543dcadb8 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "update:release-notes": "python3 scripts/update_release_notes.py", "update:generated-reference-sources": "python3 scripts/update_generated_reference_sources.py", "generate:version-compatibility-dashboard": "python3 scripts/generate_network_component_versions.py", + "generate:canton-topology-proto-link": "python3 scripts/generate_canton_topology_proto_link.py", "generate:json-api-reference": "python3 scripts/generate_json_api_reference.py", "generate:json-api-asyncapi-reference": "python3 scripts/generate_json_api_asyncapi_reference.py", "generate:grpc-ledger-api-reference": "python3 scripts/generate_grpc_ledger_api_reference.py", diff --git a/scripts/generate_canton_topology_proto_link.py b/scripts/generate_canton_topology_proto_link.py new file mode 100644 index 000000000..af1226a33 --- /dev/null +++ b/scripts/generate_canton_topology_proto_link.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import urlparse + + +REPO_ROOT = Path(__file__).resolve().parents[1] +OUTPUT_PATH = ( + REPO_ROOT / "docs-main" / "snippets" / "generated" / "canton-topology-proto-link.mdx" +) +CANTON_RELEASE_REPO = "digital-asset/canton" +CANTON_LATEST_RELEASE_URL = ( + f"https://api.github.com/repos/{CANTON_RELEASE_REPO}/releases/latest" +) +TOPOLOGY_PROTO_PATH = ( + "community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto" +) +STABLE_TAG_RE = re.compile(r"^v?(?P\d+\.\d+\.\d+)$") +USER_AGENT = "cf-docs-canton-topology-proto-link" +DEFAULT_TIMEOUT_SECONDS = 30.0 + + +def request_headers(url: str) -> dict[str, str]: + headers = { + "User-Agent": USER_AGENT, + "Accept": "application/vnd.github+json" if "api.github.com" in url else "*/*", + } + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token and urlparse(url).netloc == "api.github.com": + headers["Authorization"] = f"Bearer {token}" + headers["X-GitHub-Api-Version"] = "2022-11-28" + return headers + + +def fetch_json(url: str, timeout: float) -> object: + request = urllib.request.Request(url, headers=request_headers(url)) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.load(response) + + +def parse_canton_version(tag_or_version: str) -> str: + match = STABLE_TAG_RE.fullmatch(tag_or_version.strip()) + if not match: + raise ValueError( + f"Expected stable Canton version or tag like 3.5.14 or v3.5.14, " + f"got {tag_or_version!r}" + ) + return match.group("version") + + +def fetch_latest_stable_canton_version(timeout: float) -> str: + data = fetch_json(CANTON_LATEST_RELEASE_URL, timeout) + if not isinstance(data, dict): + raise RuntimeError(f"Expected release object from {CANTON_LATEST_RELEASE_URL}") + if data.get("prerelease") or data.get("draft"): + raise RuntimeError( + f"Latest GitHub release at {CANTON_LATEST_RELEASE_URL} is not a stable release" + ) + tag_name = data.get("tag_name") + if not isinstance(tag_name, str) or not tag_name: + raise RuntimeError(f"Missing tag_name from {CANTON_LATEST_RELEASE_URL}") + return parse_canton_version(tag_name) + + +def release_line_branch(canton_version: str) -> str: + major, minor, _patch = canton_version.split(".") + return f"release-line-{major}.{minor}" + + +def topology_proto_blob_url(release_line: str) -> str: + return ( + f"https://github.com/{CANTON_RELEASE_REPO}/blob/{release_line}/{TOPOLOGY_PROTO_PATH}" + ) + + +def topology_proto_raw_url(release_line: str) -> str: + return ( + f"https://raw.githubusercontent.com/{CANTON_RELEASE_REPO}/" + f"{release_line}/{TOPOLOGY_PROTO_PATH}" + ) + + +def assert_url_exists(url: str, timeout: float) -> None: + request = urllib.request.Request(url, method="HEAD", headers=request_headers(url)) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status = getattr(response, "status", None) or response.getcode() + if status >= 400: + raise RuntimeError(f"Unexpected HTTP status {status} for {url}") + return + except urllib.error.HTTPError as error: + if error.code in {403, 405}: + fallback = urllib.request.Request( + url, + headers={**request_headers(url), "Range": "bytes=0-0"}, + ) + try: + with urllib.request.urlopen(fallback, timeout=timeout): + return + except urllib.error.HTTPError as fallback_error: + if fallback_error.code == 404: + raise RuntimeError( + f"topology.proto URL returned 404: {url}" + ) from fallback_error + raise RuntimeError( + f"Failed to verify topology.proto URL {url}: " + f"HTTP {fallback_error.code}" + ) from fallback_error + except urllib.error.URLError as fallback_error: + raise RuntimeError( + f"Failed to verify topology.proto URL {url}: {fallback_error}" + ) from fallback_error + if error.code == 404: + raise RuntimeError(f"topology.proto URL returned 404: {url}") from error + raise RuntimeError( + f"Failed to verify topology.proto URL {url}: HTTP {error.code}" + ) from error + except urllib.error.URLError as error: + raise RuntimeError(f"Failed to verify topology.proto URL {url}: {error}") from error + + +def render_mdx(topology_proto_url: str) -> str: + escaped = topology_proto_url.replace("\\", "\\\\").replace("'", "\\'") + return f"export const topologyProtoUrl = '{escaped}';\n" + + +def write_output(path: Path, contents: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + + +def main() -> int: + try: + canton_version = fetch_latest_stable_canton_version(DEFAULT_TIMEOUT_SECONDS) + release_line = release_line_branch(canton_version) + blob_url = topology_proto_blob_url(release_line) + # Prefer the raw URL for a reliable 404; blob pages can soft-404. + assert_url_exists(topology_proto_raw_url(release_line), DEFAULT_TIMEOUT_SECONDS) + write_output(OUTPUT_PATH, render_mdx(blob_url)) + except (OSError, RuntimeError, ValueError, urllib.error.URLError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + print( + f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)} " + f"(Canton {canton_version} → {release_line})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/update_generated_reference_prs.py b/scripts/update_generated_reference_prs.py index 624f224c2..9d7192060 100644 --- a/scripts/update_generated_reference_prs.py +++ b/scripts/update_generated_reference_prs.py @@ -420,6 +420,27 @@ class UpdateTarget: "git diff --check", ), ), + UpdateTarget( + key="canton-topology-proto-link", + title="Update Canton topology.proto link", + branch="generated-docs/canton-topology-proto-link/update", + description=( + "Resolves the latest stable digital-asset/canton release, derives the matching " + "release-line branch URL for topology.proto, verifies the URL is reachable, and " + "updates the generated MDX export used by the topology reference page." + ), + generate_commands=( + ("nix-shell", "--run", "npm run generate:canton-topology-proto-link"), + ), + paths=("docs-main/snippets/generated/canton-topology-proto-link.mdx",), + summary_kind="static", + summary_path=None, + summary_label=None, + validation=( + "npm run generate:canton-topology-proto-link", + "git diff --check", + ), + ), UpdateTarget( key="canton-release-notes", title="Update Canton release notes", diff --git a/tests/test_generate_canton_topology_proto_link.py b/tests/test_generate_canton_topology_proto_link.py new file mode 100644 index 000000000..0fd406645 --- /dev/null +++ b/tests/test_generate_canton_topology_proto_link.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import importlib.util +import urllib.error +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = REPO_ROOT / "scripts" / "generate_canton_topology_proto_link.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location( + "generate_canton_topology_proto_link", + SCRIPT_PATH, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_parse_canton_version_accepts_tag_and_bare_semver() -> None: + module = load_module() + + assert module.parse_canton_version("v3.5.14") == "3.5.14" + assert module.parse_canton_version("3.6.0") == "3.6.0" + + +def test_parse_canton_version_rejects_prerelease_and_garbage() -> None: + module = load_module() + + with pytest.raises(ValueError): + module.parse_canton_version("v3.5.14-snapshot.1") + with pytest.raises(ValueError): + module.parse_canton_version("release-line-3.5") + + +def test_release_line_branch_tracks_major_minor_for_future_lines() -> None: + module = load_module() + + assert module.release_line_branch("3.5.14") == "release-line-3.5" + assert module.release_line_branch("3.6.2") == "release-line-3.6" + + +def test_topology_proto_urls_use_release_line_and_fixed_path() -> None: + module = load_module() + + assert module.topology_proto_blob_url("release-line-3.6") == ( + "https://github.com/digital-asset/canton/blob/release-line-3.6/" + "community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto" + ) + assert module.topology_proto_raw_url("release-line-3.6") == ( + "https://raw.githubusercontent.com/digital-asset/canton/release-line-3.6/" + "community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto" + ) + + +def test_render_mdx_exports_only_topology_proto_url() -> None: + module = load_module() + url = ( + "https://github.com/digital-asset/canton/blob/release-line-3.5/" + "community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto" + ) + + rendered = module.render_mdx(url) + + assert rendered == f"export const topologyProtoUrl = '{url}';\n" + assert "cantonVersion" not in rendered + assert "releaseLineBranch" not in rendered + + +def test_assert_url_exists_raises_on_404(monkeypatch: pytest.MonkeyPatch) -> None: + module = load_module() + + def boom(_request, timeout=None): # noqa: ANN001 + raise urllib.error.HTTPError( + url="https://example.test/missing", + code=404, + msg="Not Found", + hdrs=None, + fp=None, + ) + + monkeypatch.setattr(module.urllib.request, "urlopen", boom) + + with pytest.raises(RuntimeError, match="returned 404"): + module.assert_url_exists("https://example.test/missing", timeout=1.0) + + +def test_write_output_for_derived_release_line(tmp_path: Path) -> None: + module = load_module() + output = tmp_path / "canton-topology-proto-link.mdx" + canton_version = module.parse_canton_version("v3.6.1") + release_line = module.release_line_branch(canton_version) + blob_url = module.topology_proto_blob_url(release_line) + + module.write_output(output, module.render_mdx(blob_url)) + + assert output.read_text(encoding="utf-8") == ( + "export const topologyProtoUrl = " + "'https://github.com/digital-asset/canton/blob/release-line-3.6/" + "community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/topology.proto';\n" + ) + + +def test_main_fails_when_url_check_returns_404( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = load_module() + output = tmp_path / "canton-topology-proto-link.mdx" + + monkeypatch.setattr(module, "OUTPUT_PATH", output) + monkeypatch.setattr(module, "fetch_latest_stable_canton_version", lambda _timeout: "9.9.0") + + def boom(_request, timeout=None): # noqa: ANN001 + raise urllib.error.HTTPError( + url="https://raw.githubusercontent.com/digital-asset/canton/release-line-9.9/missing", + code=404, + msg="Not Found", + hdrs=None, + fp=None, + ) + + monkeypatch.setattr(module.urllib.request, "urlopen", boom) + + assert module.main() == 1 + assert not output.exists() diff --git a/tests/test_update_generated_reference_prs.py b/tests/test_update_generated_reference_prs.py index 00fd96479..33ff7409e 100644 --- a/tests/test_update_generated_reference_prs.py +++ b/tests/test_update_generated_reference_prs.py @@ -59,6 +59,7 @@ def test_update_targets_cover_all_generated_doc_surfaces() -> None: "daml-script", "typescript-bindings", "canton-metrics-reference", + "canton-topology-proto-link", "canton-release-notes", "wallet-gateway-release-notes", "wallet-sdk-release-notes",