feat: params — TunnelParams/Substrate wire types + tunnel_open_spec
- TunnelParams {resource, substrate} with deny_unknown_fields (ADR-001
loud-rejection posture); Substrate enum ships unix in v1 (OQ-TN-14)
- tunnel_open_spec(): channels/tunnel/sub, Sub-typed, alk/tunnel
channel-open marker, input schema enum [tcp,udp,unix], channel_id
output schema (minimum 1, required), tunnel:open ACL (ADR-006),
discovery description
- establishment_reason delegates to alkcall 0.7.0's typed
ChannelOpenError::establishment_reason; error re-exported through
params
- 6 unit tests: serde round-trip, unknown/missing-field rejection,
unknown-substrate rejection, spec conformance to wire.md
Verified: cargo test, clippy --all-targets -D warnings (native +
wasm32), fmt --check, wasm32 check — all clean
This commit is contained in:
+2
-1
@@ -25,5 +25,6 @@ pub mod producer;
|
|||||||
pub mod wire;
|
pub mod wire;
|
||||||
|
|
||||||
pub use error::TunnelError;
|
pub use error::TunnelError;
|
||||||
pub use params::{establishment_reason, ChannelOpenError, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
|
pub use params::ChannelOpenError;
|
||||||
|
pub use params::{establishment_reason, TUNNEL_ALPN, TUNNEL_OPEN_SCOPE};
|
||||||
pub use wire::MAX_DATAGRAM_LEN;
|
pub use wire::MAX_DATAGRAM_LEN;
|
||||||
|
|||||||
+175
-2
@@ -3,10 +3,19 @@
|
|||||||
//! `OperationSpec` builder, the scope/ALPN/op-id constants, and the
|
//! `OperationSpec` builder, the scope/ALPN/op-id constants, and the
|
||||||
//! typed establishment-reason helper (ADR-049 §4 surface).
|
//! typed establishment-reason helper (ADR-049 §4 surface).
|
||||||
//!
|
//!
|
||||||
//! Skeleton module — the schema-validation tests land with
|
//! The params layout is a one-way door once a consumer exists
|
||||||
//! `tunnels/params`; the spec builder shape is already final here.
|
//! (ADR-001): `{resource, substrate}` only — the producer owns the
|
||||||
|
//! backing; no URL-style addressing. `deny_unknown_fields` is the
|
||||||
|
//! loud-rejection posture (unknown fields fail, never silently
|
||||||
|
//! ignored); an older producer rejecting a newer substrate value is
|
||||||
|
//! the SSH "unknown channel type" posture — loud, not silent.
|
||||||
|
|
||||||
pub use alkcall::channels::client::ChannelOpenError;
|
pub use alkcall::channels::client::ChannelOpenError;
|
||||||
|
use alkcall::registry::spec::{
|
||||||
|
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
/// The `channels/tunnel/sub` operation id (the open op).
|
/// The `channels/tunnel/sub` operation id (the open op).
|
||||||
pub const OP_TUNNEL_OPEN: &str = "channels/tunnel/sub";
|
pub const OP_TUNNEL_OPEN: &str = "channels/tunnel/sub";
|
||||||
@@ -18,6 +27,70 @@ pub const TUNNEL_ALPN: &str = "alk/tunnel";
|
|||||||
/// The scope gating tunnel opens (ADR-006). Stable once published.
|
/// The scope gating tunnel opens (ADR-006). Stable once published.
|
||||||
pub const TUNNEL_OPEN_SCOPE: &str = "tunnel:open";
|
pub const TUNNEL_OPEN_SCOPE: &str = "tunnel:open";
|
||||||
|
|
||||||
|
/// The open-op input params (ADR-001). Wire-stable: both fields
|
||||||
|
/// required; renames are a breaking wire change.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct TunnelParams {
|
||||||
|
/// The produced resource identifier: the producer's stable name
|
||||||
|
/// for the tunnel target — NOT an address (the producer's
|
||||||
|
/// registry maps it to its backing, OQ-TN-11).
|
||||||
|
pub resource: String,
|
||||||
|
/// The extensible substrate discriminator; selects the data-plane
|
||||||
|
/// framing (ADR-003).
|
||||||
|
pub substrate: Substrate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The substrate discriminator (ADR-001). `Unix` ships in v1 (the
|
||||||
|
/// `local` feature implements it — OQ-TN-14); a new substrate is a
|
||||||
|
/// new value, not a format change.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Substrate {
|
||||||
|
Tcp,
|
||||||
|
Udp,
|
||||||
|
Unix,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The open-op spec (`channels/tunnel/sub`): `Sub`-typed, external,
|
||||||
|
/// channel-open marker on the `alk/tunnel` ALPN, scope-gated by
|
||||||
|
/// `tunnel:open` (ADR-006), input/output schemas per wire.md §The
|
||||||
|
/// Open Op. The description round-trips through discovery (OQ-TN-08).
|
||||||
|
pub fn tunnel_open_spec() -> OperationSpec {
|
||||||
|
OperationSpec::new(
|
||||||
|
OP_TUNNEL_OPEN,
|
||||||
|
OperationType::Sub,
|
||||||
|
Visibility::External,
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"resource": { "type": "string" },
|
||||||
|
"substrate": { "type": "string", "enum": ["tcp", "udp", "unix"] }
|
||||||
|
},
|
||||||
|
"required": ["resource", "substrate"]
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"channel_id": { "type": "integer", "minimum": 1 }
|
||||||
|
},
|
||||||
|
"required": ["channel_id"]
|
||||||
|
}),
|
||||||
|
vec![],
|
||||||
|
AccessControl {
|
||||||
|
required_scopes: vec![TUNNEL_OPEN_SCOPE.to_string()],
|
||||||
|
required_scopes_any: None,
|
||||||
|
resource_type: None,
|
||||||
|
resource_action: None,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_channel_open(ChannelOpenSpec::new(TUNNEL_ALPN))
|
||||||
|
.with_description(
|
||||||
|
"Open a tunnel channel to a produced resource (params: {resource, substrate})",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The establishment-failure reason code of a
|
/// The establishment-failure reason code of a
|
||||||
/// `channel:open_failed` error (`details.reason`): `dial_failed`,
|
/// `channel:open_failed` error (`details.reason`): `dial_failed`,
|
||||||
/// `unknown_resource`, `resource_shortage`, `handler_error`,
|
/// `unknown_resource`, `resource_shortage`, `handler_error`,
|
||||||
@@ -26,3 +99,103 @@ pub const TUNNEL_OPEN_SCOPE: &str = "tunnel:open";
|
|||||||
pub fn establishment_reason(err: &ChannelOpenError) -> Option<&str> {
|
pub fn establishment_reason(err: &ChannelOpenError) -> Option<&str> {
|
||||||
err.establishment_reason()
|
err.establishment_reason()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_round_trip() {
|
||||||
|
for substrate in [Substrate::Tcp, Substrate::Udp, Substrate::Unix] {
|
||||||
|
let params = TunnelParams {
|
||||||
|
resource: "postgres-primary".to_string(),
|
||||||
|
substrate,
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(¶ms).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
json!({"resource": "postgres-primary", "substrate": substrate_str(substrate)})
|
||||||
|
);
|
||||||
|
let back: TunnelParams = serde_json::from_value(v).unwrap();
|
||||||
|
assert_eq!(back, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn substrate_str(s: Substrate) -> &'static str {
|
||||||
|
match s {
|
||||||
|
Substrate::Tcp => "tcp",
|
||||||
|
Substrate::Udp => "udp",
|
||||||
|
Substrate::Unix => "unix",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_fields_rejected() {
|
||||||
|
let v = json!({
|
||||||
|
"resource": "postgres-primary",
|
||||||
|
"substrate": "tcp",
|
||||||
|
"extra": true
|
||||||
|
});
|
||||||
|
assert!(serde_json::from_value::<TunnelParams>(v).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_fields_rejected() {
|
||||||
|
let no_resource = json!({"substrate": "tcp"});
|
||||||
|
let no_substrate = json!({"resource": "postgres-primary"});
|
||||||
|
let empty = json!({});
|
||||||
|
assert!(serde_json::from_value::<TunnelParams>(no_resource).is_err());
|
||||||
|
assert!(serde_json::from_value::<TunnelParams>(no_substrate).is_err());
|
||||||
|
assert!(serde_json::from_value::<TunnelParams>(empty).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_substrate_rejected() {
|
||||||
|
let v = json!({"resource": "postgres-primary", "substrate": "quic"});
|
||||||
|
assert!(serde_json::from_value::<TunnelParams>(v).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_spec_matches_wire_md() {
|
||||||
|
let spec = tunnel_open_spec();
|
||||||
|
assert_eq!(spec.name, "channels/tunnel/sub");
|
||||||
|
assert_eq!(spec.op_type, OperationType::Sub);
|
||||||
|
assert_eq!(spec.visibility, Visibility::External);
|
||||||
|
assert_eq!(
|
||||||
|
spec.input_schema["properties"]["substrate"]["enum"],
|
||||||
|
json!(["tcp", "udp", "unix"])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spec.input_schema["required"],
|
||||||
|
json!(["resource", "substrate"])
|
||||||
|
);
|
||||||
|
assert_eq!(spec.output_schema["properties"]["channel_id"]["minimum"], 1);
|
||||||
|
assert_eq!(
|
||||||
|
spec.access_control.required_scopes,
|
||||||
|
vec![TUNNEL_OPEN_SCOPE.to_string()]
|
||||||
|
);
|
||||||
|
let channel_open = spec.channel_open.as_ref().expect("channel-open marker");
|
||||||
|
assert_eq!(channel_open.alpn, TUNNEL_ALPN);
|
||||||
|
assert!(spec.description.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_rejects_unknown_substrate_value() {
|
||||||
|
let schema = tunnel_open_spec().input_schema;
|
||||||
|
let substrate_ok = json!({"resource": "r", "substrate": "udp"});
|
||||||
|
let substrate_bad = json!({"resource": "r", "substrate": "sctp"});
|
||||||
|
assert!(jsonschema_valid(&schema, &substrate_ok));
|
||||||
|
assert!(!jsonschema_valid(&schema, &substrate_bad));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jsonschema_valid(_schema: &serde_json::Value, value: &serde_json::Value) -> bool {
|
||||||
|
let Ok(params) = serde_json::from_value::<TunnelParams>(value.clone()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let allowed = ["tcp", "udp", "unix"];
|
||||||
|
matches!(
|
||||||
|
substrate_str(params.substrate),
|
||||||
|
"tcp" | "udp" | "unix" if allowed.contains(&substrate_str(params.substrate))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+33
-9
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: tunnels/params
|
id: tunnels/params
|
||||||
name: TunnelParams + open-op spec (schema, scope gate, constants)
|
name: TunnelParams + open-op spec (schema, scope gate, constants)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [tunnels/crate-init]
|
depends_on: [tunnels/crate-init]
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: low
|
risk: low
|
||||||
@@ -73,16 +73,16 @@ errors (filled incrementally by later tasks); plus the re-export of
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
- [ ] `TunnelParams` round-trips serde; unknown fields rejected
|
- [x] `TunnelParams` round-trips serde; unknown fields rejected
|
||||||
- [ ] `tunnel_open_spec()` matches wire.md §The Open Op exactly (op id,
|
- [x] `tunnel_open_spec()` matches wire.md §The Open Op exactly (op id,
|
||||||
type, ALPN marker, schemas, ACL, description)
|
type, ALPN marker, schemas, ACL, description)
|
||||||
- [ ] Schema validation test: valid params pass; missing fields,
|
- [x] Schema validation test: valid params pass; missing fields,
|
||||||
unknown fields, unknown substrate values all fail
|
unknown fields, unknown substrate values all fail
|
||||||
- [ ] `establishment_reason` maps `channel:open_failed` details.reason
|
- [x] `establishment_reason` maps `channel:open_failed` details.reason
|
||||||
(POC-pinned shapes: `unknown_resource`, `dial_failed`,
|
(POC-pinned shapes: `unknown_resource`, `dial_failed`,
|
||||||
`resource_shortage`)
|
`resource_shortage`)
|
||||||
- [ ] Unit tests cover the serde + schema shapes
|
- [x] Unit tests cover the serde + schema shapes
|
||||||
- [ ] wasm32 check passes
|
- [x] wasm32 check passes
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
@@ -95,8 +95,32 @@ errors (filled incrementally by later tasks); plus the re-export of
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> Agent fills during implementation.
|
- `TunnelParams` + `Substrate` per the task's exact type shapes
|
||||||
|
(`deny_unknown_fields`, `rename_all = "lowercase"`, `Unix` in the
|
||||||
|
enum). `Eq` added to `TunnelParams` (test equality).
|
||||||
|
- `tunnel_open_spec()` lives in params.rs per the module map;
|
||||||
|
producer.rs re-exports. Output schema pins `channel_id` with
|
||||||
|
`minimum: 1` + `required` (wire.md: integer > 0) — the POC's
|
||||||
|
`minimum: 0` / no-required was loose.
|
||||||
|
- `establishment_reason` delegates to alkcall 0.7.0's
|
||||||
|
`ChannelOpenError::establishment_reason` method (upstream has it);
|
||||||
|
`ChannelOpenError` re-exported from params.rs (pub use, since lib.rs
|
||||||
|
re-exports through it).
|
||||||
|
- Schema-validation tests use the `TunnelParams` deserializer as the
|
||||||
|
enum validator (same deny-unknown/enum posture as the registry's
|
||||||
|
JSON Schema check; no jsonschema dep added for v1 — the
|
||||||
|
input_schema string is asserted structurally in
|
||||||
|
`open_spec_matches_wire_md`).
|
||||||
|
- 6 unit tests: round-trip ×3 substrates, unknown-field rejection,
|
||||||
|
missing-field rejection, unknown-substrate rejection, spec-shape
|
||||||
|
conformance, schema enum validation.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> Agent fills this on completion.
|
`src/params.rs` complete: the wire-stable `TunnelParams`/`Substrate`
|
||||||
|
types (ADR-001 exact shapes), `tunnel_open_spec()` (op id, Sub type,
|
||||||
|
External visibility, input/output schemas with the 3-value substrate
|
||||||
|
enum, `tunnel:open` ACL, channel-open marker, description),
|
||||||
|
constants, and the `establishment_reason` helper. Verified: `cargo
|
||||||
|
test` (6 passed), clippy --all-targets -D warnings (native + wasm32),
|
||||||
|
fmt --check, wasm32 check — all clean.
|
||||||
Reference in New Issue
Block a user