coda-node 0.1.0__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coda_node-0.1.1/.agents/skills/update-changelog/SKILL.md +22 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/.github/workflows/ci.yml +1 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/.github/workflows/publish.yml +8 -0
- coda_node-0.1.1/.github/workflows/release.yml +118 -0
- coda_node-0.1.1/CHANGELOG.md +19 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/PKG-INFO +2 -2
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/node/CONNECT_PROTOCOL.md +11 -10
- {coda_node-0.1.0 → coda_node-0.1.1}/pyproject.toml +1 -1
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/__init__.py +1 -1
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/errors.py +5 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/app.py +2 -11
- coda_node-0.1.1/src/coda_node/server/device_topology.py +20 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/heartbeat.py +28 -1
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/vpn/service.py +42 -6
- coda_node-0.1.1/tests/test_device_topology.py +25 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_heartbeat.py +38 -1
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_main.py +0 -28
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_node.py +76 -6
- {coda_node-0.1.0 → coda_node-0.1.1}/uv.lock +122 -99
- {coda_node-0.1.0 → coda_node-0.1.1}/.agents/skills/read-docs/SKILL.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/.agents/skills/update-docs/SKILL.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/.gitignore +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/.pre-commit-config.yaml +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/.python-version +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/Dockerfile.e2e +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/LICENSE +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/README.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/INDEX.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/auth/INDEX.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/auth/KEYPAIR_LIFECYCLE.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/auth/SIGNING.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/configuration/ENVIRONMENT_VARIABLES.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/configuration/INDEX.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/configuration/SETTINGS_REFERENCE.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/frameworks/DEVICE_CONFIG.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/frameworks/FRAMEWORK_PROTOCOL.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/frameworks/INDEX.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/frameworks/REGISTRY.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/jobs/CONSUMER.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/jobs/EXECUTOR.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/jobs/INDEX.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/jobs/IR_SCHEMA.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/node/CREDENTIAL_PERSISTENCE.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/node/INDEX.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/node/TOKEN_LIFECYCLE.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/CLI.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/ERROR_HANDLING.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/GRACEFUL_SHUTDOWN.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/HEALTH_ENDPOINTS.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/INDEX.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/vpn/CLOUD_INFRASTRUCTURE.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/vpn/HEALTH_MONITORING.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/vpn/INDEX.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/vpn/TUNNEL_LIFECYCLE.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/webhooks/DELIVERY.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/webhooks/INDEX.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/docs/webhooks/PAYLOAD_FORMAT.md +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/py.typed +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/__init__.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/auth.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/cli.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/config.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/consumer.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/daemon.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/executor.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/ir.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/qasm.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/webhook.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/vpn/__init__.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/vpn/guard.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/conftest.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_auth.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_cli.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_config.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_consumer.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_daemon.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_frameworks.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_ir.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_qasm.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_vpn.py +0 -0
- {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_webhook.py +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: update-changelog
|
|
3
|
+
description: Add an entry to CHANGELOG.md for a new version.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
argument-hint: [version]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Update Changelog
|
|
9
|
+
|
|
10
|
+
Add a new entry to `CHANGELOG.md` for the given version.
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
1. Read `CHANGELOG.md`.
|
|
15
|
+
2. Review recent commits since the last tagged release with `git log`.
|
|
16
|
+
3. Add a new `## [x.y.z] - YYYY-MM-DD` section below the header, above the previous entry.
|
|
17
|
+
4. Summarise changes concisely under `### Added`, `### Changed`, `### Fixed`, or `### Removed` subsections as appropriate.
|
|
18
|
+
5. Follow the existing style — short bullet points, no links.
|
|
19
|
+
|
|
20
|
+
## Arguments
|
|
21
|
+
|
|
22
|
+
`/update-changelog 0.3.0` — the version string to use in the heading. If omitted, ask the user.
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
name: Publish to PyPI
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
tag:
|
|
7
|
+
description: "Existing release tag to publish (e.g. v0.2.0)"
|
|
8
|
+
required: true
|
|
9
|
+
type: string
|
|
4
10
|
release:
|
|
5
11
|
types:
|
|
6
12
|
- created
|
|
@@ -15,6 +21,8 @@ jobs:
|
|
|
15
21
|
steps:
|
|
16
22
|
- name: Checkout repository
|
|
17
23
|
uses: actions/checkout@v4
|
|
24
|
+
with:
|
|
25
|
+
ref: refs/tags/${{ inputs.tag || github.event.release.tag_name }}
|
|
18
26
|
|
|
19
27
|
- name: Set up Python
|
|
20
28
|
uses: actions/setup-python@v6
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
version:
|
|
7
|
+
description: "Version to release (e.g. 0.2.0)"
|
|
8
|
+
required: true
|
|
9
|
+
type: string
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: release
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
env:
|
|
16
|
+
VERSION: ${{ inputs.version }}
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
validate:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
with:
|
|
24
|
+
fetch-depth: 0
|
|
25
|
+
|
|
26
|
+
- name: Require main branch
|
|
27
|
+
run: |
|
|
28
|
+
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
|
|
29
|
+
echo "::error::Releases must be run from main"
|
|
30
|
+
exit 1
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
- name: Validate semver format
|
|
34
|
+
run: |
|
|
35
|
+
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
36
|
+
echo "::error::Version '$VERSION' is not valid semver (expected x.y.z)"
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
- name: Check tag does not already exist
|
|
41
|
+
run: |
|
|
42
|
+
if git show-ref --verify --quiet "refs/tags/v${VERSION}"; then
|
|
43
|
+
echo "::error::Tag v${VERSION} already exists"
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
- name: Check CHANGELOG has entry for version
|
|
48
|
+
run: |
|
|
49
|
+
if ! awk -v heading="## [$VERSION]" '
|
|
50
|
+
index($0, heading) == 1 {found=1}
|
|
51
|
+
END {exit !found}
|
|
52
|
+
' CHANGELOG.md; then
|
|
53
|
+
echo "::error::CHANGELOG.md has no entry for version $VERSION"
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
ci:
|
|
58
|
+
needs: validate
|
|
59
|
+
uses: ./.github/workflows/ci.yml
|
|
60
|
+
|
|
61
|
+
release:
|
|
62
|
+
needs: ci
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
permissions:
|
|
65
|
+
contents: write
|
|
66
|
+
actions: write
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@v4
|
|
69
|
+
with:
|
|
70
|
+
fetch-depth: 0
|
|
71
|
+
ref: ${{ github.sha }}
|
|
72
|
+
|
|
73
|
+
- name: Configure git
|
|
74
|
+
run: |
|
|
75
|
+
git config user.name "github-actions[bot]"
|
|
76
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
77
|
+
|
|
78
|
+
- uses: astral-sh/setup-uv@v5
|
|
79
|
+
|
|
80
|
+
- name: Bump version in pyproject.toml
|
|
81
|
+
run: sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
|
|
82
|
+
|
|
83
|
+
- name: Bump version in __init__.py
|
|
84
|
+
run: sed -i "s/^__version__ = \".*\"/__version__ = \"${VERSION}\"/" src/coda_node/__init__.py
|
|
85
|
+
|
|
86
|
+
- name: Refresh lockfile
|
|
87
|
+
run: uv lock
|
|
88
|
+
|
|
89
|
+
- name: Commit and tag
|
|
90
|
+
run: |
|
|
91
|
+
git add pyproject.toml src/coda_node/__init__.py uv.lock
|
|
92
|
+
if ! git diff --cached --quiet; then
|
|
93
|
+
git commit -m "release: v${VERSION}"
|
|
94
|
+
fi
|
|
95
|
+
git tag "v${VERSION}"
|
|
96
|
+
git push --atomic origin HEAD:refs/heads/main "refs/tags/v${VERSION}"
|
|
97
|
+
|
|
98
|
+
- name: Extract release notes from CHANGELOG
|
|
99
|
+
run: |
|
|
100
|
+
awk -v heading="## [$VERSION]" '
|
|
101
|
+
index($0, heading) == 1 {found=1; next}
|
|
102
|
+
found && /^## \[/ {exit}
|
|
103
|
+
found
|
|
104
|
+
' CHANGELOG.md > release-notes.md
|
|
105
|
+
|
|
106
|
+
- name: Create GitHub Release
|
|
107
|
+
env:
|
|
108
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
109
|
+
run: |
|
|
110
|
+
gh release create "v${VERSION}" \
|
|
111
|
+
--verify-tag \
|
|
112
|
+
--title "v${VERSION}" \
|
|
113
|
+
--notes-file release-notes.md
|
|
114
|
+
|
|
115
|
+
- name: Start PyPI publishing
|
|
116
|
+
env:
|
|
117
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
118
|
+
run: gh workflow run publish.yml --ref main --field "tag=v${VERSION}"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## [0.1.1] - 2026-04-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Connectivity reporting in heartbeat and connect payloads.
|
|
13
|
+
- `HeartbeatRejectedError` when the platform rejects a heartbeat.
|
|
14
|
+
|
|
15
|
+
## [0.1.0] - 2026-04-01
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Initial release: JWT auth, VPN management, Redis job consumer, webhook delivery, heartbeat, IR/QASM support, `JobExecutor` protocol, daemon mode, and CLI.
|
|
@@ -25,31 +25,32 @@ The cloud uses `token.split(".").length === 3` to detect JWTs.
|
|
|
25
25
|
|
|
26
26
|
```json
|
|
27
27
|
{
|
|
28
|
-
"machine_fingerprint": "hostname-macaddr"
|
|
28
|
+
"machine_fingerprint": "hostname-macaddr",
|
|
29
|
+
"connectivity": [[1, 0], [2, 1]]
|
|
29
30
|
}
|
|
30
31
|
```
|
|
31
32
|
|
|
32
33
|
| Field | Type | Required | Description |
|
|
33
34
|
|---|---|---|---|
|
|
34
35
|
| `machine_fingerprint` | `string` | Yes | Stable host identifier (`{hostname}-{mac_address}`). Generated by the node if not set. Stored on first run and verified on subsequent reconnects. |
|
|
36
|
+
| `connectivity` | `number[][]` \| omitted | No | Live edge list from the executor device spec (same shape as heartbeat). When present, the cloud validates every endpoint against the registered `qpu_devices.qubits` / bootstrap token `num_qubits` **before** issuing the bundle, so a topology mismatch fails at connect time. Omitted when the executor has no device (e.g. `NoopExecutor`). |
|
|
35
37
|
|
|
36
38
|
### Node-Side Request Construction
|
|
37
39
|
|
|
38
|
-
**Node** (`fetch_node_bundle` in `vpn/service.py`):
|
|
40
|
+
**Node** (`fetch_node_bundle` / `fetch_reconnect_bundle` in `vpn/service.py`):
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
auth_header = f"Bearer {settings.node_token}"
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
**Reconnect** (`fetch_reconnect_bundle` in `vpn/service.py`):
|
|
42
|
+
After `connect_settings` loads the executor via `_connectivity_payload_for_connect()`,
|
|
43
|
+
the JSON body includes `machine_fingerprint` and, when available, `connectivity`.
|
|
46
44
|
|
|
47
45
|
```python
|
|
48
46
|
payload = {"machine_fingerprint": settings.node_machine_fingerprint}
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
if connectivity is not None:
|
|
48
|
+
payload["connectivity"] = connectivity
|
|
49
|
+
auth_header = f"Bearer {settings.node_token}" # or JWT for reconnect
|
|
51
50
|
```
|
|
52
51
|
|
|
52
|
+
**Reconnect** uses the same payload shape with a JWT `Authorization` header.
|
|
53
|
+
|
|
53
54
|
Both call `_post_connect()` with exponential-backoff retry on 5xx and
|
|
54
55
|
transport errors (configurable via `CODA_NODE_CONNECT_RETRIES`,
|
|
55
56
|
default 3).
|
|
@@ -10,6 +10,7 @@ __all__ = [
|
|
|
10
10
|
"CodaError",
|
|
11
11
|
"ConfigError",
|
|
12
12
|
"ExecutorError",
|
|
13
|
+
"HeartbeatRejectedError",
|
|
13
14
|
"NodeError",
|
|
14
15
|
"VPNError",
|
|
15
16
|
"WebhookError",
|
|
@@ -36,6 +37,10 @@ class NodeError(CodaError):
|
|
|
36
37
|
"""Node provisioning or reconnect failure."""
|
|
37
38
|
|
|
38
39
|
|
|
40
|
+
class HeartbeatRejectedError(CodaError):
|
|
41
|
+
"""Cloud returned a non-success response to a QPU heartbeat POST."""
|
|
42
|
+
|
|
43
|
+
|
|
39
44
|
class ExecutorError(CodaError):
|
|
40
45
|
"""Executor loading or job execution failure."""
|
|
41
46
|
|
|
@@ -19,6 +19,7 @@ from fastapi.responses import JSONResponse
|
|
|
19
19
|
|
|
20
20
|
from coda_node.server.config import Settings
|
|
21
21
|
from coda_node.server.consumer import RedisConsumer
|
|
22
|
+
from coda_node.server.device_topology import resolve_connectivity_from_device_spec
|
|
22
23
|
from coda_node.server.executor import JobExecutor, load_executor
|
|
23
24
|
from coda_node.server.heartbeat import HeartbeatClient
|
|
24
25
|
from coda_node.server.webhook import WebhookClient
|
|
@@ -32,16 +33,6 @@ from coda_node.vpn import (
|
|
|
32
33
|
logger = logging.getLogger(__name__)
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
def _resolve_connectivity(device_spec: object | None) -> list[list[int]] | None:
|
|
36
|
-
"""Extract qubit connectivity from a device spec, preferring directed edges."""
|
|
37
|
-
if device_spec is None:
|
|
38
|
-
return None
|
|
39
|
-
directed = getattr(device_spec, "directed_edges", None)
|
|
40
|
-
if directed:
|
|
41
|
-
return [list(e) for e in directed]
|
|
42
|
-
return [list(e) for e in device_spec.logical_edges] # type: ignore[attr-defined]
|
|
43
|
-
|
|
44
|
-
|
|
45
36
|
async def _on_vpn_state_change(state: ServiceState) -> None:
|
|
46
37
|
"""Log VPN state transitions at WARNING level."""
|
|
47
38
|
logger.warning("VPN state changed: %s", state.value)
|
|
@@ -93,7 +84,7 @@ def create_app(executor: JobExecutor | None = None) -> FastAPI:
|
|
|
93
84
|
runner = executor or load_executor(settings)
|
|
94
85
|
|
|
95
86
|
device_spec = getattr(runner, "device", None)
|
|
96
|
-
connectivity =
|
|
87
|
+
connectivity = resolve_connectivity_from_device_spec(device_spec)
|
|
97
88
|
|
|
98
89
|
webhook = WebhookClient(
|
|
99
90
|
qpu_id=settings.qpu_id,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Extract qubit connectivity from an executor device spec for cloud reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["resolve_connectivity_from_device_spec"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_connectivity_from_device_spec(
|
|
9
|
+
device_spec: object | None,
|
|
10
|
+
) -> list[list[int]] | None:
|
|
11
|
+
"""Extract qubit connectivity from a device spec, preferring directed edges.
|
|
12
|
+
|
|
13
|
+
Matches the shape sent in node heartbeats and connect payloads.
|
|
14
|
+
"""
|
|
15
|
+
if device_spec is None:
|
|
16
|
+
return None
|
|
17
|
+
directed = getattr(device_spec, "directed_edges", None)
|
|
18
|
+
if directed:
|
|
19
|
+
return [list(e) for e in directed]
|
|
20
|
+
return [list(e) for e in device_spec.logical_edges] # type: ignore[attr-defined]
|
|
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
|
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
|
|
21
|
+
from coda_node.errors import HeartbeatRejectedError
|
|
21
22
|
from coda_node.server.auth import sign_token
|
|
22
23
|
|
|
23
24
|
if TYPE_CHECKING:
|
|
@@ -31,6 +32,20 @@ _DEFAULT_INTERVAL = 30
|
|
|
31
32
|
_HTTP_TIMEOUT = 10.0
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def _format_heartbeat_error_response(response: httpx.Response) -> str:
|
|
36
|
+
"""Best-effort message from a failed heartbeat response body."""
|
|
37
|
+
try:
|
|
38
|
+
data = response.json()
|
|
39
|
+
if isinstance(data, dict) and "error" in data:
|
|
40
|
+
return str(data["error"])
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
text = (response.text or "").strip()
|
|
44
|
+
if text:
|
|
45
|
+
return text[:800]
|
|
46
|
+
return response.reason_phrase or "unknown error"
|
|
47
|
+
|
|
48
|
+
|
|
34
49
|
class HeartbeatClient:
|
|
35
50
|
"""Send periodic authenticated heartbeats to the Coda cloud.
|
|
36
51
|
|
|
@@ -87,7 +102,12 @@ class HeartbeatClient:
|
|
|
87
102
|
json=body,
|
|
88
103
|
headers={"Authorization": f"Bearer {token}", **self._extra_headers},
|
|
89
104
|
)
|
|
90
|
-
response.
|
|
105
|
+
if response.is_success:
|
|
106
|
+
return
|
|
107
|
+
detail = _format_heartbeat_error_response(response)
|
|
108
|
+
raise HeartbeatRejectedError(
|
|
109
|
+
f"HTTP {response.status_code} from heartbeat endpoint: {detail}"
|
|
110
|
+
)
|
|
91
111
|
|
|
92
112
|
async def run(self) -> None:
|
|
93
113
|
"""Run the heartbeat loop until :meth:`stop` is called."""
|
|
@@ -99,6 +119,13 @@ class HeartbeatClient:
|
|
|
99
119
|
try:
|
|
100
120
|
await self._send()
|
|
101
121
|
logger.debug("Heartbeat sent to %s", self._url)
|
|
122
|
+
except HeartbeatRejectedError as exc:
|
|
123
|
+
logger.warning(
|
|
124
|
+
"Heartbeat rejected by cloud (%s). Fix configuration or align "
|
|
125
|
+
"qpu_devices.qubits with the node's reported connectivity. %s",
|
|
126
|
+
self._url,
|
|
127
|
+
exc,
|
|
128
|
+
)
|
|
102
129
|
except Exception:
|
|
103
130
|
logger.warning("Heartbeat to %s failed", self._url, exc_info=True)
|
|
104
131
|
await asyncio.sleep(self._interval)
|
|
@@ -363,7 +363,34 @@ async def _post_connect(
|
|
|
363
363
|
) from last_exc
|
|
364
364
|
|
|
365
365
|
|
|
366
|
-
|
|
366
|
+
def _connectivity_payload_for_connect(settings: Settings) -> list[list[int]] | None:
|
|
367
|
+
"""Load the executor and derive live topology for the connect handshake.
|
|
368
|
+
|
|
369
|
+
If the executor cannot be loaded (missing config, import errors), returns
|
|
370
|
+
``None`` so connect proceeds without ``connectivity`` (cloud skips validation).
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
from coda_node.server.device_topology import (
|
|
374
|
+
resolve_connectivity_from_device_spec,
|
|
375
|
+
)
|
|
376
|
+
from coda_node.server.executor import load_executor
|
|
377
|
+
|
|
378
|
+
runner = load_executor(settings)
|
|
379
|
+
device_spec = getattr(runner, "device", None)
|
|
380
|
+
return resolve_connectivity_from_device_spec(device_spec)
|
|
381
|
+
except Exception as exc:
|
|
382
|
+
logger.warning(
|
|
383
|
+
"Skipping live connectivity on connect handshake (executor unavailable): %s",
|
|
384
|
+
exc,
|
|
385
|
+
)
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def fetch_node_bundle(
|
|
390
|
+
settings: Settings,
|
|
391
|
+
*,
|
|
392
|
+
connectivity: list[list[int]] | None = None,
|
|
393
|
+
) -> dict[str, Any]:
|
|
367
394
|
"""Fetch the provisioning bundle using a one-time node token.
|
|
368
395
|
|
|
369
396
|
POSTs the machine fingerprint to the Coda connect endpoint. The
|
|
@@ -383,9 +410,11 @@ async def fetch_node_bundle(settings: Settings) -> dict[str, Any]:
|
|
|
383
410
|
if not settings.node_token:
|
|
384
411
|
raise NodeError("node token is empty")
|
|
385
412
|
|
|
386
|
-
payload = {
|
|
413
|
+
payload: dict[str, Any] = {
|
|
387
414
|
"machine_fingerprint": _resolve_machine_fingerprint(settings),
|
|
388
415
|
}
|
|
416
|
+
if connectivity is not None:
|
|
417
|
+
payload["connectivity"] = connectivity
|
|
389
418
|
return await _post_connect(
|
|
390
419
|
settings,
|
|
391
420
|
auth_header=f"Bearer {settings.node_token}",
|
|
@@ -394,7 +423,11 @@ async def fetch_node_bundle(settings: Settings) -> dict[str, Any]:
|
|
|
394
423
|
)
|
|
395
424
|
|
|
396
425
|
|
|
397
|
-
async def fetch_reconnect_bundle(
|
|
426
|
+
async def fetch_reconnect_bundle(
|
|
427
|
+
settings: Settings,
|
|
428
|
+
*,
|
|
429
|
+
connectivity: list[list[int]] | None = None,
|
|
430
|
+
) -> dict[str, Any]:
|
|
398
431
|
"""Fetch a reconnect bundle using stored JWT credentials.
|
|
399
432
|
|
|
400
433
|
Authenticates with a freshly signed JWT (instead of a node
|
|
@@ -411,9 +444,11 @@ async def fetch_reconnect_bundle(settings: Settings) -> dict[str, Any]:
|
|
|
411
444
|
Raises:
|
|
412
445
|
NodeError: If the HTTP request fails.
|
|
413
446
|
"""
|
|
414
|
-
payload = {
|
|
447
|
+
payload: dict[str, Any] = {
|
|
415
448
|
"machine_fingerprint": _resolve_machine_fingerprint(settings),
|
|
416
449
|
}
|
|
450
|
+
if connectivity is not None:
|
|
451
|
+
payload["connectivity"] = connectivity
|
|
417
452
|
token = sign_token(
|
|
418
453
|
settings.qpu_id,
|
|
419
454
|
settings.jwt_private_key,
|
|
@@ -539,11 +574,12 @@ async def connect_settings(settings: Settings) -> None:
|
|
|
539
574
|
NodeError: On provisioning or reconnect failure.
|
|
540
575
|
"""
|
|
541
576
|
_resolve_machine_fingerprint(settings)
|
|
577
|
+
connectivity = _connectivity_payload_for_connect(settings)
|
|
542
578
|
if settings.node_token:
|
|
543
|
-
bundle = await fetch_node_bundle(settings)
|
|
579
|
+
bundle = await fetch_node_bundle(settings, connectivity=connectivity)
|
|
544
580
|
else:
|
|
545
581
|
await ensure_persisted_vpn(settings)
|
|
546
|
-
bundle = await fetch_reconnect_bundle(settings)
|
|
582
|
+
bundle = await fetch_reconnect_bundle(settings, connectivity=connectivity)
|
|
547
583
|
try:
|
|
548
584
|
await apply_node_bundle(settings, bundle)
|
|
549
585
|
await asyncio.to_thread(_persist_runtime_config, settings)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Tests for device topology helpers."""
|
|
2
|
+
|
|
3
|
+
from coda_node.server.device_topology import resolve_connectivity_from_device_spec
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _FakeDevice:
|
|
7
|
+
def __init__(
|
|
8
|
+
self, directed: list[tuple[int, int]] | None, logical: list[tuple[int, int]]
|
|
9
|
+
) -> None:
|
|
10
|
+
self.directed_edges = directed
|
|
11
|
+
self.logical_edges = logical
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_prefers_directed_edges() -> None:
|
|
15
|
+
spec = _FakeDevice([(1, 0), (2, 1)], [(0, 1)])
|
|
16
|
+
assert resolve_connectivity_from_device_spec(spec) == [[1, 0], [2, 1]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_falls_back_to_logical_edges() -> None:
|
|
20
|
+
spec = _FakeDevice(None, [(0, 1), (1, 2)])
|
|
21
|
+
assert resolve_connectivity_from_device_spec(spec) == [[0, 1], [1, 2]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_none_spec() -> None:
|
|
25
|
+
assert resolve_connectivity_from_device_spec(None) is None
|
|
@@ -6,7 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
-
from coda_node.
|
|
9
|
+
from coda_node.errors import HeartbeatRejectedError
|
|
10
|
+
from coda_node.server.heartbeat import HeartbeatClient, _format_heartbeat_error_response
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def _make_consumer(**overrides: object) -> MagicMock:
|
|
@@ -18,6 +19,42 @@ def _make_consumer(**overrides: object) -> MagicMock:
|
|
|
18
19
|
return consumer
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
def test_format_heartbeat_error_response_prefers_json_error_field() -> None:
|
|
23
|
+
response = MagicMock()
|
|
24
|
+
response.json.return_value = {"error": "Connectivity edge (19, 18) outside [0, 19)"}
|
|
25
|
+
assert (
|
|
26
|
+
_format_heartbeat_error_response(response)
|
|
27
|
+
== "Connectivity edge (19, 18) outside [0, 19)"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@patch("coda_node.server.heartbeat.sign_token", return_value="mock-jwt")
|
|
32
|
+
async def test_send_raises_heartbeat_rejected_on_http_error(
|
|
33
|
+
_mock_sign: MagicMock,
|
|
34
|
+
) -> None:
|
|
35
|
+
mock_response = MagicMock()
|
|
36
|
+
mock_response.is_success = False
|
|
37
|
+
mock_response.status_code = 400
|
|
38
|
+
mock_response.json.return_value = {"error": "topology mismatch"}
|
|
39
|
+
mock_response.reason_phrase = "Bad Request"
|
|
40
|
+
mock_response.text = '{"error": "topology mismatch"}'
|
|
41
|
+
mock_http = AsyncMock()
|
|
42
|
+
mock_http.post = AsyncMock(return_value=mock_response)
|
|
43
|
+
|
|
44
|
+
consumer = _make_consumer()
|
|
45
|
+
client = HeartbeatClient(
|
|
46
|
+
heartbeat_url="https://example.com/heartbeat",
|
|
47
|
+
qpu_id="qpu-1",
|
|
48
|
+
jwt_private_key="fake-key",
|
|
49
|
+
jwt_key_id="kid-1",
|
|
50
|
+
consumer=consumer,
|
|
51
|
+
)
|
|
52
|
+
client._client = mock_http
|
|
53
|
+
|
|
54
|
+
with pytest.raises(HeartbeatRejectedError, match=r"400.*topology mismatch"):
|
|
55
|
+
await client._send()
|
|
56
|
+
|
|
57
|
+
|
|
21
58
|
@patch("coda_node.server.heartbeat.sign_token", return_value="mock-jwt")
|
|
22
59
|
async def test_send_posts_authenticated_payload(_mock_sign: MagicMock) -> None:
|
|
23
60
|
mock_response = AsyncMock()
|
|
@@ -9,7 +9,6 @@ from fastapi import FastAPI, Request
|
|
|
9
9
|
from fastapi.responses import JSONResponse
|
|
10
10
|
from fastapi.testclient import TestClient
|
|
11
11
|
|
|
12
|
-
from coda_node.server.app import _resolve_connectivity
|
|
13
12
|
from coda_node.vpn import ServiceState
|
|
14
13
|
|
|
15
14
|
|
|
@@ -104,30 +103,3 @@ def test_ready_endpoint_redis_unhealthy() -> None:
|
|
|
104
103
|
body = response.json()
|
|
105
104
|
assert body["ready"] is False
|
|
106
105
|
assert body["redis_healthy"] is False
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def test_resolve_connectivity_prefers_directed_edges() -> None:
|
|
110
|
-
device = MagicMock()
|
|
111
|
-
device.directed_edges = [(1, 0), (2, 1)]
|
|
112
|
-
device.logical_edges = [(0, 1), (1, 2)]
|
|
113
|
-
|
|
114
|
-
assert _resolve_connectivity(device) == [[1, 0], [2, 1]]
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def test_resolve_connectivity_falls_back_to_logical_edges() -> None:
|
|
118
|
-
device = MagicMock(spec=["logical_edges"])
|
|
119
|
-
device.logical_edges = [(0, 1), (1, 2)]
|
|
120
|
-
|
|
121
|
-
assert _resolve_connectivity(device) == [[0, 1], [1, 2]]
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def test_resolve_connectivity_returns_none_without_device() -> None:
|
|
125
|
-
assert _resolve_connectivity(None) is None
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_resolve_connectivity_falls_back_when_directed_edges_empty() -> None:
|
|
129
|
-
device = MagicMock()
|
|
130
|
-
device.directed_edges = []
|
|
131
|
-
device.logical_edges = [(0, 1), (1, 2)]
|
|
132
|
-
|
|
133
|
-
assert _resolve_connectivity(device) == [[0, 1], [1, 2]]
|