coda-node 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Coda node: a standalone server runtime for quantum processing units.
2
+
3
+ This package provides everything needed to connect an execution backend
4
+ (QPU or simulator) to the Coda cloud platform. It handles JWT-based
5
+ authentication, VPN tunnel management, Redis job consumption, and signed
6
+ webhook delivery so that backend authors only need to implement the
7
+ :class:`~coda_node.server.executor.JobExecutor` protocol.
8
+
9
+ Typical usage::
10
+
11
+ uv run coda-node start --token <node-token>
12
+ """
13
+
14
+ from coda_node.errors import CodaError
15
+ from coda_node.server import app, create_app
16
+
17
+ __version__ = "0.1.0"
18
+ __all__ = ["CodaError", "__version__", "app", "create_app"]
coda_node/errors.py ADDED
@@ -0,0 +1,44 @@
1
+ """Shared exception hierarchy for coda-node.
2
+
3
+ All domain-specific exceptions inherit from :class:`CodaError` so that
4
+ callers can distinguish expected operational errors from unexpected bugs
5
+ with a single ``except CodaError`` clause.
6
+ """
7
+
8
+ __all__ = [
9
+ "AuthError",
10
+ "CodaError",
11
+ "ConfigError",
12
+ "ExecutorError",
13
+ "NodeError",
14
+ "VPNError",
15
+ "WebhookError",
16
+ ]
17
+
18
+
19
+ class CodaError(Exception):
20
+ """Base exception for all coda-node errors."""
21
+
22
+
23
+ class ConfigError(CodaError):
24
+ """Invalid or missing configuration."""
25
+
26
+
27
+ class AuthError(CodaError):
28
+ """JWT authentication failure."""
29
+
30
+
31
+ class VPNError(CodaError):
32
+ """VPN tunnel or health check failure."""
33
+
34
+
35
+ class NodeError(CodaError):
36
+ """Node provisioning or reconnect failure."""
37
+
38
+
39
+ class ExecutorError(CodaError):
40
+ """Executor loading or job execution failure."""
41
+
42
+
43
+ class WebhookError(CodaError):
44
+ """Webhook delivery failure."""
coda_node/py.typed ADDED
File without changes
@@ -0,0 +1,9 @@
1
+ """Server runtime: FastAPI app, configuration, job consumption, and webhooks.
2
+
3
+ Re-exports the application factory :func:`create_app` and the default
4
+ ``app`` instance used by the ``coda start`` CLI command.
5
+ """
6
+
7
+ from coda_node.server.app import app, create_app
8
+
9
+ __all__ = ["app", "create_app"]
@@ -0,0 +1,191 @@
1
+ """FastAPI application for the standalone Coda-connected node server.
2
+
3
+ The application is constructed via :func:`create_app`, which wires up a
4
+ lifespan that boots the VPN guard, Redis consumer, heartbeat reporter,
5
+ and webhook client. A module-level ``app`` instance is provided for
6
+ ``uvicorn`` import-string references (``coda_node.server.app:app``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from collections.abc import AsyncGenerator
14
+ from contextlib import asynccontextmanager, suppress
15
+
16
+ import redis.asyncio as aioredis
17
+ from fastapi import FastAPI
18
+ from fastapi.responses import JSONResponse
19
+
20
+ from coda_node.server.config import Settings
21
+ from coda_node.server.consumer import RedisConsumer
22
+ from coda_node.server.executor import JobExecutor, load_executor
23
+ from coda_node.server.heartbeat import HeartbeatClient
24
+ from coda_node.server.webhook import WebhookClient
25
+ from coda_node.vpn import (
26
+ ServiceState,
27
+ VPNGuard,
28
+ connect_settings,
29
+ kill_openvpn_daemon,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
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
+ async def _on_vpn_state_change(state: ServiceState) -> None:
46
+ """Log VPN state transitions at WARNING level."""
47
+ logger.warning("VPN state changed: %s", state.value)
48
+
49
+
50
+ def create_app(executor: JobExecutor | None = None) -> FastAPI:
51
+ """Build a fully-wired FastAPI application.
52
+
53
+ The returned app uses an async lifespan that:
54
+
55
+ 1. Loads ``Settings`` from environment / persisted config.
56
+ 2. Connects to the Coda cloud (node or JWT reconnect).
57
+ 3. Runs a VPN preflight check and starts background monitoring.
58
+ 4. Opens a Redis consumer loop that dispatches jobs to *executor*.
59
+ 5. On shutdown, drains in-flight work and tears down resources.
60
+
61
+ Args:
62
+ executor: Custom execution backend. When ``None``, the executor
63
+ is resolved from ``CODA_EXECUTOR_FACTORY`` or falls back to
64
+ :class:`~coda_node.server.executor.NoopExecutor`.
65
+
66
+ Returns:
67
+ A configured :class:`~fastapi.FastAPI` instance with ``/health``
68
+ and ``/ready`` endpoints.
69
+ """
70
+
71
+ @asynccontextmanager
72
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
73
+ """Boot services on startup and tear them down on shutdown."""
74
+ settings = Settings()
75
+ await connect_settings(settings)
76
+
77
+ guard = VPNGuard(
78
+ probe_targets=settings.vpn_probe_urls,
79
+ interface_hint=settings.vpn_interface_hint,
80
+ check_interval_sec=settings.vpn_check_interval_sec,
81
+ vpn_required=settings.vpn_required,
82
+ extra_headers=settings.node_connect_headers,
83
+ )
84
+ vpn_status = await guard.preflight()
85
+ if (
86
+ not vpn_status.ok
87
+ and settings.vpn_required
88
+ and not settings.allow_degraded_startup
89
+ ):
90
+ raise RuntimeError(f"VPN preflight failed: {vpn_status.reason}")
91
+
92
+ redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
93
+ runner = executor or load_executor(settings)
94
+
95
+ device_spec = getattr(runner, "device", None)
96
+ connectivity = _resolve_connectivity(device_spec)
97
+
98
+ webhook = WebhookClient(
99
+ qpu_id=settings.qpu_id,
100
+ jwt_private_key=settings.jwt_private_key,
101
+ jwt_key_id=settings.jwt_key_id,
102
+ extra_headers=settings.node_connect_headers,
103
+ )
104
+ consumer = RedisConsumer(
105
+ redis=redis_client,
106
+ runner=runner,
107
+ webhook=webhook,
108
+ qpu_id=settings.qpu_id,
109
+ )
110
+
111
+ heartbeat = HeartbeatClient(
112
+ heartbeat_url=settings.heartbeat_url,
113
+ qpu_id=settings.qpu_id,
114
+ jwt_private_key=settings.jwt_private_key,
115
+ jwt_key_id=settings.jwt_key_id,
116
+ consumer=consumer,
117
+ interval=settings.heartbeat_interval_sec,
118
+ connectivity=connectivity,
119
+ extra_headers=settings.node_connect_headers,
120
+ )
121
+
122
+ watch_task = asyncio.create_task(guard.watch(_on_vpn_state_change))
123
+ consumer_task = asyncio.create_task(consumer.consume_loop())
124
+ heartbeat_task = asyncio.create_task(heartbeat.run())
125
+
126
+ app.state.settings = settings
127
+ app.state.guard = guard
128
+ app.state.consumer = consumer
129
+ app.state.webhook = webhook
130
+ app.state.heartbeat = heartbeat
131
+
132
+ yield
133
+
134
+ guard.stop()
135
+ watch_task.cancel()
136
+ with suppress(asyncio.CancelledError):
137
+ await watch_task
138
+
139
+ await heartbeat.close()
140
+ heartbeat_task.cancel()
141
+ with suppress(asyncio.CancelledError):
142
+ await heartbeat_task
143
+
144
+ drained = await consumer.drain(timeout=settings.shutdown_drain_timeout_sec)
145
+ if not drained:
146
+ logger.warning("Drain timeout expired, cancelling in-flight job")
147
+ consumer_task.cancel()
148
+ with suppress(asyncio.CancelledError):
149
+ await consumer_task
150
+
151
+ await webhook.close()
152
+ await redis_client.aclose()
153
+ kill_openvpn_daemon()
154
+
155
+ app = FastAPI(title="Coda Node", lifespan=lifespan)
156
+
157
+ @app.get("/health")
158
+ async def health() -> dict[str, str]:
159
+ """Liveness probe — returns 200 if the process is running."""
160
+ return {"status": "ok"}
161
+
162
+ async def _check_readiness() -> dict[str, object]:
163
+ """Gather composite readiness state from VPN guard and Redis consumer."""
164
+ guard: VPNGuard = app.state.guard
165
+ consumer: RedisConsumer = app.state.consumer
166
+ vpn_ok = guard.is_ready
167
+ redis_ok = consumer.redis_healthy
168
+ return {
169
+ "ready": vpn_ok and redis_ok,
170
+ "vpn_state": guard.state.value,
171
+ "redis_healthy": redis_ok,
172
+ "current_job": consumer.current_job_id,
173
+ }
174
+
175
+ @app.get("/ready")
176
+ async def ready() -> JSONResponse:
177
+ """Readiness probe — returns 200 when healthy, 503 otherwise."""
178
+ try:
179
+ result = await asyncio.wait_for(_check_readiness(), timeout=5.0)
180
+ status_code = 200 if result["ready"] else 503
181
+ return JSONResponse(content=result, status_code=status_code)
182
+ except TimeoutError:
183
+ return JSONResponse(
184
+ content={"ready": False, "reason": "health check timeout"},
185
+ status_code=503,
186
+ )
187
+
188
+ return app
189
+
190
+
191
+ app = create_app()
@@ -0,0 +1,192 @@
1
+ """RS256 JWT authentication between the node runtime and the Coda cloud.
2
+
3
+ Every request from the node to the Coda API (webhooks, heartbeats,
4
+ reconnect) is authenticated with a short-lived JWT signed with an RSA
5
+ private key. The cloud verifies the signature using the corresponding
6
+ public key registered during node provisioning.
7
+
8
+ Key concepts:
9
+
10
+ * **KeyPair** -- an RSA keypair generated locally or received from the
11
+ node endpoint.
12
+ * **sign_token** -- creates a signed JWT for outbound requests.
13
+ * **verify_token** / **verify_token_with_key** -- validate inbound JWTs
14
+ (used primarily in tests and potential future inbound auth).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime, timedelta
22
+
23
+ import jwt
24
+ from cryptography.hazmat.primitives import serialization
25
+ from cryptography.hazmat.primitives.asymmetric import rsa
26
+
27
+ __all__ = [
28
+ "DEFAULT_ISSUER",
29
+ "DEFAULT_TTL",
30
+ "KeyPair",
31
+ "generate_keypair",
32
+ "sign_token",
33
+ "verify_token",
34
+ "verify_token_with_key",
35
+ ]
36
+
37
+ DEFAULT_ISSUER = "coda"
38
+ DEFAULT_TTL = timedelta(hours=1)
39
+ KEY_SIZE = 2048
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class KeyPair:
44
+ """RSA keypair for JWT authentication."""
45
+
46
+ private_key_pem: str
47
+ public_key_pem: str
48
+ key_id: str
49
+
50
+
51
+ def generate_keypair(key_id: str) -> KeyPair:
52
+ """Generate a fresh PEM-encoded RSA-2048 keypair.
53
+
54
+ Args:
55
+ key_id: Logical identifier embedded in JWTs as the ``kid`` header.
56
+ Typically the QPU node identifier.
57
+
58
+ Returns:
59
+ A :class:`KeyPair` containing PEM-encoded private and public keys.
60
+ """
61
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=KEY_SIZE)
62
+
63
+ private_pem = private_key.private_bytes(
64
+ encoding=serialization.Encoding.PEM,
65
+ format=serialization.PrivateFormat.PKCS8,
66
+ encryption_algorithm=serialization.NoEncryption(),
67
+ ).decode()
68
+
69
+ public_pem = (
70
+ private_key.public_key()
71
+ .public_bytes(
72
+ encoding=serialization.Encoding.PEM,
73
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
74
+ )
75
+ .decode()
76
+ )
77
+
78
+ return KeyPair(
79
+ private_key_pem=private_pem,
80
+ public_key_pem=public_pem,
81
+ key_id=key_id,
82
+ )
83
+
84
+
85
+ def sign_token(
86
+ subject: str,
87
+ private_key_pem: str,
88
+ *,
89
+ issuer: str = DEFAULT_ISSUER,
90
+ ttl: timedelta = DEFAULT_TTL,
91
+ key_id: str | None = None,
92
+ ) -> str:
93
+ """Create a short-lived RS256-signed JWT.
94
+
95
+ The token carries ``sub``, ``iss``, ``iat``, and ``exp`` claims and a
96
+ ``kid`` header so the verifier can look up the correct public key.
97
+
98
+ Args:
99
+ subject: The ``sub`` claim -- usually the QPU node identifier.
100
+ private_key_pem: PEM-encoded RSA private key used to sign.
101
+ issuer: The ``iss`` claim.
102
+ ttl: Token lifetime from now.
103
+ key_id: Explicit ``kid`` header value. Defaults to *subject*.
104
+
105
+ Returns:
106
+ A compact-serialised JWT string.
107
+ """
108
+ now = datetime.now(UTC)
109
+ payload = {
110
+ "sub": subject,
111
+ "iss": issuer,
112
+ "iat": now,
113
+ "exp": now + ttl,
114
+ }
115
+ headers = {"kid": key_id or subject}
116
+ return jwt.encode(payload, private_key_pem, algorithm="RS256", headers=headers)
117
+
118
+
119
+ def verify_token(
120
+ token: str,
121
+ get_public_key: Callable[[str], str | None],
122
+ *,
123
+ issuer: str = DEFAULT_ISSUER,
124
+ ) -> dict[str, object]:
125
+ """Verify and decode a JWT by resolving its ``kid`` to a public key.
126
+
127
+ Args:
128
+ token: The compact-serialised JWT to verify.
129
+ get_public_key: Callable that maps a ``kid`` string to a
130
+ PEM-encoded public key, or ``None`` if the key is unknown.
131
+ issuer: Expected ``iss`` claim value.
132
+
133
+ Returns:
134
+ The decoded payload as a dictionary.
135
+
136
+ Raises:
137
+ jwt.InvalidTokenError: If the token is malformed, the ``kid`` is
138
+ missing or unknown, or signature / claim validation fails.
139
+ """
140
+ try:
141
+ header = jwt.get_unverified_header(token)
142
+ except jwt.DecodeError as exc:
143
+ raise jwt.InvalidTokenError(f"Malformed token header: {exc}") from exc
144
+
145
+ kid = header.get("kid")
146
+ if not kid:
147
+ raise jwt.InvalidTokenError("Token missing 'kid' header")
148
+
149
+ public_key_pem = get_public_key(kid)
150
+ if not public_key_pem:
151
+ raise jwt.InvalidTokenError(f"Unknown key_id: {kid}")
152
+
153
+ result: dict[str, object] = jwt.decode(
154
+ token,
155
+ public_key_pem,
156
+ algorithms=["RS256"],
157
+ issuer=issuer,
158
+ options={"require": ["sub", "iss", "exp", "iat"]},
159
+ )
160
+ return result
161
+
162
+
163
+ def verify_token_with_key(
164
+ token: str,
165
+ public_key_pem: str,
166
+ *,
167
+ issuer: str = DEFAULT_ISSUER,
168
+ ) -> dict[str, object]:
169
+ """Verify and decode a JWT when the public key is already known.
170
+
171
+ Unlike :func:`verify_token`, this skips the ``kid`` lookup and uses
172
+ the provided key directly.
173
+
174
+ Args:
175
+ token: The compact-serialised JWT to verify.
176
+ public_key_pem: PEM-encoded RSA public key.
177
+ issuer: Expected ``iss`` claim value.
178
+
179
+ Returns:
180
+ The decoded payload as a dictionary.
181
+
182
+ Raises:
183
+ jwt.InvalidTokenError: If signature or claim validation fails.
184
+ """
185
+ result: dict[str, object] = jwt.decode(
186
+ token,
187
+ public_key_pem,
188
+ algorithms=["RS256"],
189
+ issuer=issuer,
190
+ options={"require": ["sub", "iss", "exp", "iat"]},
191
+ )
192
+ return result