Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 4 additions & 14 deletions docs-main/overview/reference/topology.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

<div className="todo">

link to topology proto? \<[https://github.com/DACH-NY/canton/issues/25656](https://github.com/DACH-NY/canton/issues/25656)\>

</div>
For definitions of all mappings and the respective unique keys, please refer to <a href={topologyProtoUrl}>topology.proto</a>.

### Serial

Expand Down Expand Up @@ -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.

<div className="todo">

link to topology proto? \<[https://github.com/DACH-NY/canton/issues/25656](https://github.com/DACH-NY/canton/issues/25656)\>

</div>
For definitions of all mappings and the respective authorization rules, please refer to <a href={topologyProtoUrl}>topology.proto</a>.

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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
160 changes: 160 additions & 0 deletions scripts/generate_canton_topology_proto_link.py
Original file line number Diff line number Diff line change
@@ -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<version>\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())
21 changes: 21 additions & 0 deletions scripts/update_generated_reference_prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions tests/test_generate_canton_topology_proto_link.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions tests/test_update_generated_reference_prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down