mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-21 06:11:31 +08:00
Merge remote-tracking branch 'origin/main' into HEAD
Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
This commit is contained in:
@@ -210,9 +210,13 @@ Common findings:
|
||||
host's IPv4 default route. Rootless pasta uses the private source address
|
||||
selected by that route; rootful Podman uses the bridge gateway address.
|
||||
- Callback discovery reports that the requested address equals the primary
|
||||
listener: configure a distinct primary address. For Podman Machine, keep the
|
||||
IPv4 loopback callback separate by using an IPv6-loopback primary such as
|
||||
`[::1]:17670`.
|
||||
listener: configure a distinct primary address. For Podman Machine, bind the
|
||||
primary listener to IPv6 loopback, for example
|
||||
`bind_address = "[::1]:17670"`, and register the CLI endpoint as
|
||||
`https://localhost:17670`. The generated certificate includes `localhost`,
|
||||
while a raw `https://[::1]:17670` endpoint can fail TLS setup with
|
||||
`invalid dns name`. This leaves `127.0.0.1:17670` available for the
|
||||
callback-only listener.
|
||||
- Rootless slirp4netns, another named helper, or missing helper metadata
|
||||
requires an explicitly remote `grpc_endpoint`. An explicit `host_gateway_ip`
|
||||
cannot bypass slirp4netns host-loopback isolation. Do not work around
|
||||
|
||||
@@ -218,6 +218,25 @@ jobs:
|
||||
- name: Test
|
||||
run: mise run test:python
|
||||
|
||||
go:
|
||||
name: Go SDK
|
||||
needs: pr_metadata
|
||||
if: needs.pr_metadata.outputs.should_run == 'true'
|
||||
runs-on: linux-amd64-cpu8
|
||||
container:
|
||||
image: ghcr.io/nvidia/openshell/ci:latest
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install tools
|
||||
run: mise install --locked
|
||||
|
||||
- name: Lint, build, test, proto-check
|
||||
run: mise run go:ci
|
||||
|
||||
markdown:
|
||||
name: Markdown
|
||||
needs: pr_metadata
|
||||
|
||||
@@ -64,6 +64,7 @@ pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
coverage.out
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
@@ -204,6 +204,15 @@ ocsf_emit!(event);
|
||||
- `mise run e2e` — End-to-end tests against a running gateway. Run for infrastructure, sandbox, or policy changes.
|
||||
- `mise run ci` — Full local CI (lint + compile/type checks + tests). Run before opening a PR.
|
||||
|
||||
## Go SDK (`sdk/go/`)
|
||||
|
||||
- The Go SDK lives in `sdk/go/` with module path `github.com/NVIDIA/OpenShell/sdk/go`.
|
||||
- Run `mise run go:ci` for the full SDK CI pipeline (lint, build, test, proto-check, docs-check).
|
||||
- Proto bindings are generated with `mise run go:proto:gen` from the `.proto` files in `proto/`.
|
||||
- Domain types in `sdk/go/openshell/v1/types/` must not import proto packages.
|
||||
- Converters in `sdk/go/openshell/v1/internal/converter/` deep-copy slices and maps at boundaries.
|
||||
- Tests use bufconn for in-process gRPC and testify for assertions.
|
||||
|
||||
## Python
|
||||
|
||||
- Always use `uv` for Python commands (e.g., `uv pip install`, `uv run`, `uv venv`)
|
||||
|
||||
@@ -434,6 +434,7 @@ Bazel does not yet cover `mise run gateway`, `mise run sandbox`, `mise run e2e`,
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `crates/` | Rust crates |
|
||||
| `python/` | Python SDK and bindings |
|
||||
| `sdk/go/` | Go SDK (types, gRPC clients, converters) |
|
||||
| `proto/` | Protocol buffer definitions |
|
||||
| `tasks/` | `mise` task definitions and build scripts |
|
||||
| `deploy/` | Dockerfiles, Helm chart, Kubernetes manifests |
|
||||
|
||||
Generated
+82
-63
@@ -127,7 +127,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -138,7 +138,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1356,15 +1356,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "5.0.0-rc.0"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf"
|
||||
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.11.2",
|
||||
"fiat-crypto",
|
||||
"rand_core 0.10.1",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -1615,9 +1616,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.17.0-rc.18"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54fb064faabbee66e1fc8e5c5a9458d4269dc2d8b638fe86a425adb2510d1a96"
|
||||
checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
|
||||
dependencies = [
|
||||
"der 0.8.0",
|
||||
"digest 0.11.2",
|
||||
@@ -1640,9 +1641,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "3.0.0-rc.0"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60"
|
||||
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
@@ -1665,9 +1666,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.14.0-rc.33"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "102d3643d30dd8b559613c5cced68317199597fffb278cdc88daa2ef7fafc935"
|
||||
checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
@@ -1677,7 +1678,6 @@ dependencies = [
|
||||
"group",
|
||||
"hkdf 0.13.0",
|
||||
"hybrid-array",
|
||||
"once_cell",
|
||||
"pem-rfc7468 1.0.0",
|
||||
"pkcs8 0.11.0",
|
||||
"rand_core 0.10.1",
|
||||
@@ -1737,7 +1737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2869,33 +2869,21 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "10.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"getrandom 0.2.17",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"signature 2.2.0",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3371,7 +3359,7 @@ dependencies = [
|
||||
"module-lattice",
|
||||
"pkcs8 0.11.0",
|
||||
"rand_core 0.10.1",
|
||||
"sha3",
|
||||
"sha3 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3467,7 +3455,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3588,7 +3576,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http-auth",
|
||||
"jsonwebtoken 10.3.0",
|
||||
"jsonwebtoken",
|
||||
"lazy_static",
|
||||
"oci-spec",
|
||||
"olpc-cjson",
|
||||
@@ -4109,7 +4097,7 @@ dependencies = [
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
"jsonwebtoken 9.3.1",
|
||||
"jsonwebtoken",
|
||||
"k8s-openapi",
|
||||
"kube",
|
||||
"metrics",
|
||||
@@ -4145,6 +4133,7 @@ dependencies = [
|
||||
"rcgen",
|
||||
"reqwest 0.12.28",
|
||||
"ring",
|
||||
"rsa 0.9.10",
|
||||
"russh",
|
||||
"rustix 1.1.4",
|
||||
"rustls 0.23.38",
|
||||
@@ -4428,9 +4417,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.14.0-rc.10"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41adc63effe99d48837a8cc0e6d7a77e32ae6a07f6000df466178dbc2193093e"
|
||||
checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
@@ -4441,9 +4430,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "p384"
|
||||
version = "0.14.0-rc.10"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bd5333afa5ae0347f39e6a0f2c9c155da431583fd71fe5555bd0521b4ccaf02"
|
||||
checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f"
|
||||
dependencies = [
|
||||
"ecdsa",
|
||||
"elliptic-curve",
|
||||
@@ -4455,9 +4444,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "p521"
|
||||
version = "0.14.0-rc.10"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3a5297f53dc16d35909060ba3032cff7867e8809f01e273ff325579d5f0ceae"
|
||||
checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"ecdsa",
|
||||
@@ -4848,11 +4837,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.14.0-rc.10"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d2793f22b9b6fd11ef3ac1d59bf003c2573593e4968702341605c2748fd90bf"
|
||||
checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06"
|
||||
dependencies = [
|
||||
"elliptic-curve",
|
||||
"once_cell",
|
||||
"primefield",
|
||||
"serdect",
|
||||
"wnaf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5355,12 +5348,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rfc6979"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5236ce872cac07e0fb3969b0cbf468c7d2f37d432f1b627dcb7b8d34563fb0c3"
|
||||
checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
|
||||
dependencies = [
|
||||
"crypto-bigint",
|
||||
"hmac 0.13.0",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5430,9 +5423,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "russh"
|
||||
version = "0.61.2"
|
||||
version = "0.62.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0"
|
||||
checksum = "da7c230e0ed9cbeb92fbad6c8848985d6df2a1464c0dc247a021abd666e9005e"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -5487,7 +5480,7 @@ dependencies = [
|
||||
"sec1",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sha3",
|
||||
"sha3 0.12.0",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0",
|
||||
"ssh-encoding",
|
||||
@@ -5502,9 +5495,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "russh-cryptovec"
|
||||
version = "0.61.0"
|
||||
version = "0.62.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "443f6bbcfacb34a1aab2b12b99bf08e0c63abdc5a0db261901365df9d57fff51"
|
||||
checksum = "3aec6cb630dbe85d72ffd7bcd95f07e1bd69f9f270ee8adfa1afe443a6331438"
|
||||
dependencies = [
|
||||
"log",
|
||||
"nix 0.31.3",
|
||||
@@ -5583,7 +5576,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5663,7 +5656,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6047,6 +6040,17 @@ dependencies = [
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
|
||||
dependencies = [
|
||||
"digest 0.11.2",
|
||||
"keccak",
|
||||
"sponge-cursor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -6182,7 +6186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6242,6 +6246,12 @@ dependencies = [
|
||||
"der 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sponge-cursor"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.8.6"
|
||||
@@ -6432,17 +6442,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ssh-cipher"
|
||||
version = "0.3.0-rc.9"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10db6f219196a8528f9ec904d9d45cdad692d65b0e57e72be4dedd1c5fddce36"
|
||||
checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"cbc",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ctutils",
|
||||
"des",
|
||||
"poly1305",
|
||||
@@ -6452,9 +6460,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ssh-encoding"
|
||||
version = "0.3.0-rc.9"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7abf34aa716da5d5b4c496936d042ea282ab392092cd68a72ef6a8863ff8c96a"
|
||||
checksum = "7b54d0ed0498daf3f78d82e00e28c8eec9d75a067c4cfbcc7a0f7d0f4077749e"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"bytes",
|
||||
@@ -6467,9 +6475,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ssh-key"
|
||||
version = "0.7.0-rc.10"
|
||||
version = "0.7.0-rc.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45735ce3dea95690e4a9e414c4cfde7f79835063c3dcd35881df85a84118e74b"
|
||||
checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"bcrypt-pbkdf",
|
||||
@@ -6647,9 +6655,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -6675,7 +6683,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6711,7 +6719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7708,7 +7716,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8250,6 +8258,17 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wnaf"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
|
||||
dependencies = [
|
||||
"ff",
|
||||
"group",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ base64 = "0.22"
|
||||
# Crypto / Auth
|
||||
sha2 = "0.10"
|
||||
rand = "0.9"
|
||||
jsonwebtoken = "9"
|
||||
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
|
||||
getrandom = "0.3"
|
||||
ring = "0.17"
|
||||
spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "tracing"] }
|
||||
|
||||
@@ -6,6 +6,56 @@ bazel_dep(name = "rules_cc", version = "0.2.20")
|
||||
bazel_dep(name = "rules_proto", version = "7.1.0")
|
||||
bazel_dep(name = "protobuf", version = "34.0.bcr.1")
|
||||
|
||||
bazel_lib_toolchains = use_extension("@bazel_lib//lib:extensions.bzl", "toolchains")
|
||||
bazel_lib_toolchains.zstd()
|
||||
use_repo(bazel_lib_toolchains, "zstd_toolchains")
|
||||
|
||||
register_toolchains("@zstd_toolchains//:all")
|
||||
|
||||
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
|
||||
http_archive(
|
||||
name = "vm_runtime_darwin_aarch64",
|
||||
build_file_content = """
|
||||
exports_files([
|
||||
"gvproxy",
|
||||
"libkrun.dylib",
|
||||
"libkrunfw.5.dylib",
|
||||
"umoci",
|
||||
])
|
||||
""",
|
||||
integrity = "sha256-KSAKryBFZiytwYwXB0CR++u4tGlA5ficf7X/nen7qQE=",
|
||||
urls = ["https://github.com/NVIDIA/OpenShell/releases/download/vm-runtime/vm-runtime-darwin-aarch64.tar.zst"],
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "vm_runtime_linux_aarch64",
|
||||
build_file_content = """
|
||||
exports_files([
|
||||
"gvproxy",
|
||||
"libkrun.so",
|
||||
"libkrunfw.so.5",
|
||||
"umoci",
|
||||
])
|
||||
""",
|
||||
integrity = "sha256-zn0P4NtEKp7Euy44HfQ3YoBNijF2VWFTaLLsase+Y1A=",
|
||||
urls = ["https://github.com/NVIDIA/OpenShell/releases/download/vm-runtime/vm-runtime-linux-aarch64.tar.zst"],
|
||||
)
|
||||
|
||||
http_archive(
|
||||
name = "vm_runtime_linux_x86_64",
|
||||
build_file_content = """
|
||||
exports_files([
|
||||
"gvproxy",
|
||||
"libkrun.so",
|
||||
"libkrunfw.so.5",
|
||||
"umoci",
|
||||
])
|
||||
""",
|
||||
integrity = "sha256-urdMjarDN5WJ6eCmpddlcnEn2v3qp28D7+e6PtyhPr0=",
|
||||
urls = ["https://github.com/NVIDIA/OpenShell/releases/download/vm-runtime/vm-runtime-linux-x86_64.tar.zst"],
|
||||
)
|
||||
|
||||
include("//bazel/annotations:aws-lc-sys.MODULE.bazel")
|
||||
include("//bazel/annotations:z3-sys.MODULE.bazel")
|
||||
include("//bazel/annotations:zstd-sys.MODULE.bazel")
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
# 
|
||||
<!-- markdownlint-disable MD033 MD041 -->
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/assets/openshell-banner-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/brand/assets/openshell-banner-light.png">
|
||||
<img alt="OpenShell" src="docs/brand/assets/openshell-banner-light.png" width="430">
|
||||
</picture>
|
||||
|
||||
<!-- markdownlint-enable MD033 MD041 -->
|
||||
|
||||
[](https://github.com/NVIDIA/OpenShell/blob/main/LICENSE)
|
||||
[](https://pypi.org/project/openshell/)
|
||||
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
exports_files(["cargo_version.bzl"])
|
||||
exports_files([
|
||||
"cargo_version.bzl",
|
||||
"vm_runtime.bzl",
|
||||
])
|
||||
|
||||
@@ -44,6 +44,7 @@ platform_transition_binary(
|
||||
binary = "//crates/openshell-sandbox:openshell-sandbox-bin",
|
||||
tags = ["manual"],
|
||||
target_platform = "@rules_rs//rs/platforms:x86_64-unknown-linux-musl",
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
platform_transition_binary(
|
||||
@@ -52,6 +53,7 @@ platform_transition_binary(
|
||||
binary = "//crates/openshell-sandbox:openshell-sandbox-bin",
|
||||
tags = ["manual"],
|
||||
target_platform = "@rules_rs//rs/platforms:aarch64-unknown-linux-musl",
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
platform_transition_binary(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
load("//bazel:vm_runtime.bzl", "vm_runtime_bundle")
|
||||
|
||||
config_setting(
|
||||
name = "darwin_aarch64",
|
||||
constraint_values = [
|
||||
"@platforms//cpu:aarch64",
|
||||
"@platforms//os:osx",
|
||||
],
|
||||
)
|
||||
|
||||
config_setting(
|
||||
name = "linux_aarch64",
|
||||
constraint_values = [
|
||||
"@platforms//cpu:aarch64",
|
||||
"@platforms//os:linux",
|
||||
],
|
||||
)
|
||||
|
||||
config_setting(
|
||||
name = "linux_x86_64",
|
||||
constraint_values = [
|
||||
"@platforms//cpu:x86_64",
|
||||
"@platforms//os:linux",
|
||||
],
|
||||
)
|
||||
|
||||
vm_runtime_bundle(
|
||||
name = "runtime",
|
||||
gvproxy = select({
|
||||
":darwin_aarch64": "@vm_runtime_darwin_aarch64//:gvproxy",
|
||||
":linux_aarch64": "@vm_runtime_linux_aarch64//:gvproxy",
|
||||
":linux_x86_64": "@vm_runtime_linux_x86_64//:gvproxy",
|
||||
}),
|
||||
libkrun = select({
|
||||
":darwin_aarch64": "@vm_runtime_darwin_aarch64//:libkrun.dylib",
|
||||
":linux_aarch64": "@vm_runtime_linux_aarch64//:libkrun.so",
|
||||
":linux_x86_64": "@vm_runtime_linux_x86_64//:libkrun.so",
|
||||
}),
|
||||
libkrun_name = select({
|
||||
":darwin_aarch64": "libkrun.dylib",
|
||||
":linux_aarch64": "libkrun.so",
|
||||
":linux_x86_64": "libkrun.so",
|
||||
}),
|
||||
libkrunfw = select({
|
||||
":darwin_aarch64": "@vm_runtime_darwin_aarch64//:libkrunfw.5.dylib",
|
||||
":linux_aarch64": "@vm_runtime_linux_aarch64//:libkrunfw.so.5",
|
||||
":linux_x86_64": "@vm_runtime_linux_x86_64//:libkrunfw.so.5",
|
||||
}),
|
||||
libkrunfw_name = select({
|
||||
":darwin_aarch64": "libkrunfw.5.dylib",
|
||||
":linux_aarch64": "libkrunfw.so.5",
|
||||
":linux_x86_64": "libkrunfw.so.5",
|
||||
}),
|
||||
supervisor = select({
|
||||
":darwin_aarch64": "//bazel/releases:openshell_sandbox_linux_aarch64",
|
||||
":linux_aarch64": "//bazel/releases:openshell_sandbox_linux_aarch64",
|
||||
":linux_x86_64": "//bazel/releases:openshell_sandbox_linux_x86_64",
|
||||
}),
|
||||
tags = ["manual"],
|
||||
target_compatible_with = select({
|
||||
":darwin_aarch64": [],
|
||||
":linux_aarch64": [],
|
||||
":linux_x86_64": [],
|
||||
"//conditions:default": ["@platforms//:incompatible"],
|
||||
}),
|
||||
umoci = select({
|
||||
":darwin_aarch64": "@vm_runtime_darwin_aarch64//:umoci",
|
||||
":linux_aarch64": "@vm_runtime_linux_aarch64//:umoci",
|
||||
":linux_x86_64": "@vm_runtime_linux_x86_64//:umoci",
|
||||
}),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Rules for staging the embedded openshell-driver-vm runtime."""
|
||||
|
||||
_ZSTD_TOOLCHAIN = "@bazel_lib//lib:zstd_toolchain_type"
|
||||
|
||||
def _vm_runtime_bundle_impl(ctx):
|
||||
output = ctx.actions.declare_directory(ctx.label.name)
|
||||
zstd = ctx.toolchains[_ZSTD_TOOLCHAIN].zstdinfo.binary
|
||||
|
||||
resources = [
|
||||
(ctx.file.libkrun, ctx.attr.libkrun_name),
|
||||
(ctx.file.libkrunfw, ctx.attr.libkrunfw_name),
|
||||
(ctx.file.gvproxy, "gvproxy"),
|
||||
(ctx.executable.supervisor, "openshell-sandbox"),
|
||||
(ctx.file.umoci, "umoci"),
|
||||
]
|
||||
commands = ["mkdir -p '{}'".format(output.path)]
|
||||
for source, name in resources:
|
||||
commands.append("'{}' -q -f '{}' -o '{}/{}.zst'".format(
|
||||
zstd.path,
|
||||
source.path,
|
||||
output.path,
|
||||
name,
|
||||
))
|
||||
|
||||
ctx.actions.run_shell(
|
||||
command = "set -euo pipefail\n{}".format("\n".join(commands)),
|
||||
inputs = [source for source, _ in resources],
|
||||
outputs = [output],
|
||||
tools = [zstd],
|
||||
)
|
||||
|
||||
return [DefaultInfo(files = depset([output]))]
|
||||
|
||||
vm_runtime_bundle = rule(
|
||||
implementation = _vm_runtime_bundle_impl,
|
||||
attrs = {
|
||||
"gvproxy": attr.label(allow_single_file = True, mandatory = True),
|
||||
"libkrun": attr.label(allow_single_file = True, mandatory = True),
|
||||
"libkrun_name": attr.string(mandatory = True),
|
||||
"libkrunfw": attr.label(allow_single_file = True, mandatory = True),
|
||||
"libkrunfw_name": attr.string(mandatory = True),
|
||||
"supervisor": attr.label(executable = True, cfg = "target", mandatory = True),
|
||||
"umoci": attr.label(allow_single_file = True, mandatory = True),
|
||||
},
|
||||
toolchains = [_ZSTD_TOOLCHAIN],
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Repo-level buf module. Declares proto/ as the single module so buf generate,
|
||||
# buf lint, buf breaking, and the editor LSP all resolve imports the same way.
|
||||
# Code generation lives with each consumer (see sdk/go/buf.gen.yaml,
|
||||
# sdk/typescript/buf.gen.yaml); this file owns the module boundary and proto
|
||||
# validation policy.
|
||||
version: v2
|
||||
modules:
|
||||
- path: proto
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
except:
|
||||
# Flat proto/ layout: all files live in one directory with nested
|
||||
# packages (openshell.v1, openshell.sandbox.v1, ...). Adopting these
|
||||
# would require restructuring the tree into openshell/<svc>/v1/ and
|
||||
# updating every Rust/Python/TS codegen path and import.
|
||||
- DIRECTORY_SAME_PACKAGE
|
||||
- PACKAGE_DIRECTORY_MATCH
|
||||
# Established API shape: services are unsuffixed (OpenShell, not
|
||||
# OpenShellService) and RPCs reuse shared request/response messages with
|
||||
# short names. Renaming these is a breaking change across the codebase.
|
||||
- RPC_REQUEST_RESPONSE_UNIQUE
|
||||
- RPC_REQUEST_STANDARD_NAME
|
||||
- RPC_RESPONSE_STANDARD_NAME
|
||||
- SERVICE_SUFFIX
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
@@ -745,11 +745,11 @@ pub fn resolve_ssh_gateway(
|
||||
// Remote cluster: use the remote host but keep the cluster URL port.
|
||||
return (host.to_string(), cluster_port);
|
||||
}
|
||||
// Both endpoints loopback. The unspecified addresses (0.0.0.0 / ::)
|
||||
// are bind-only — they aren't valid connect targets and aren't in TLS
|
||||
// cert SANs, so fall back to the cluster URL's host (which the CLI
|
||||
// is already using to reach the gateway).
|
||||
if gateway_host == "0.0.0.0" || gateway_host == "::" {
|
||||
// Unspecified addresses are bind-only, and tonic cannot use an IPv6
|
||||
// literal as a TLS DNS name. In those cases, keep the cluster URL's
|
||||
// already-reachable authority. Other loopback addresses retain the
|
||||
// gateway-reported host.
|
||||
if matches!(gateway_host, "0.0.0.0" | "::" | "::1") {
|
||||
return (host.to_string(), cluster_port);
|
||||
}
|
||||
return (gateway_host.to_string(), cluster_port);
|
||||
@@ -1026,6 +1026,13 @@ mod tests {
|
||||
assert_eq!(port, 443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ssh_gateway_preserves_loopback_tls_authority() {
|
||||
let (host, port) = resolve_ssh_gateway("::1", 8080, "https://localhost:8443");
|
||||
assert_eq!(host, "localhost");
|
||||
assert_eq!(port, 8443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ssh_gateway_swaps_zeros_for_loopback_cluster_host() {
|
||||
// The gateway binds 0.0.0.0 but advertises that bind address via the
|
||||
|
||||
@@ -46,16 +46,20 @@ pub mod compute {
|
||||
pub use super::generated::openshell::compute::v1;
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::all,
|
||||
clippy::pedantic,
|
||||
clippy::nursery,
|
||||
dead_code,
|
||||
unused_imports,
|
||||
unused_qualifications,
|
||||
rust_2018_idioms
|
||||
)]
|
||||
pub mod credentials {
|
||||
#[allow(
|
||||
clippy::all,
|
||||
clippy::pedantic,
|
||||
clippy::nursery,
|
||||
dead_code,
|
||||
unused_imports,
|
||||
unused_qualifications,
|
||||
rust_2018_idioms
|
||||
)]
|
||||
#[cfg(bazel)]
|
||||
pub use super::generated::openshell::credentials::v1;
|
||||
|
||||
#[cfg(not(bazel))]
|
||||
pub mod v1 {
|
||||
include!(concat!(env!("OUT_DIR"), "/openshell.credentials.v1.rs"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
load("@crates//:defs.bzl", "aliases", "all_crate_deps")
|
||||
load("@rules_rs//rs:rust_library.bzl", "rust_library")
|
||||
load("@rules_rs//rs:rust_test.bzl", "rust_test")
|
||||
load("@rules_rust//rust:defs.bzl", "rustfmt_test")
|
||||
|
||||
rust_library(
|
||||
name = "openshell-driver-db-credstore",
|
||||
srcs = glob(["src/**/*.rs"]),
|
||||
aliases = aliases(),
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True),
|
||||
)
|
||||
|
||||
rust_test(
|
||||
name = "openshell-driver-db-credstore_test",
|
||||
crate = ":openshell-driver-db-credstore",
|
||||
deps = all_crate_deps(normal_dev = True),
|
||||
)
|
||||
|
||||
rustfmt_test(
|
||||
name = "rustfmt_test",
|
||||
targets = [
|
||||
":openshell-driver-db-credstore",
|
||||
":openshell-driver-db-credstore_test",
|
||||
],
|
||||
visibility = ["//crates:__pkg__"],
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
load("@crates//:defs.bzl", "aliases", "all_crate_deps")
|
||||
load("@rules_rs//rs:rust_binary.bzl", "rust_binary")
|
||||
load("@rules_rs//rs:rust_library.bzl", "rust_library")
|
||||
load("@rules_rs//rs:rust_test.bzl", "rust_test")
|
||||
load("@rules_rust//rust:defs.bzl", "rustfmt_test")
|
||||
|
||||
rust_library(
|
||||
name = "openshell-driver-kubernetes-secrets",
|
||||
srcs = glob(
|
||||
["src/**/*.rs"],
|
||||
exclude = ["src/main.rs"],
|
||||
),
|
||||
aliases = aliases(),
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True),
|
||||
)
|
||||
|
||||
rust_binary(
|
||||
name = "openshell-driver-kubernetes-secrets_bin",
|
||||
srcs = ["src/main.rs"],
|
||||
aliases = aliases(),
|
||||
binary_name = "openshell-driver-kubernetes-secrets",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True) + [":openshell-driver-kubernetes-secrets"],
|
||||
)
|
||||
|
||||
rust_test(
|
||||
name = "openshell-driver-kubernetes-secrets_test",
|
||||
crate = ":openshell-driver-kubernetes-secrets",
|
||||
deps = all_crate_deps(normal_dev = True),
|
||||
)
|
||||
|
||||
rustfmt_test(
|
||||
name = "rustfmt_test",
|
||||
targets = [
|
||||
":openshell-driver-kubernetes-secrets",
|
||||
":openshell-driver-kubernetes-secrets_bin",
|
||||
":openshell-driver-kubernetes-secrets_test",
|
||||
],
|
||||
visibility = ["//crates:__pkg__"],
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
load("@crates//:defs.bzl", "aliases", "all_crate_deps")
|
||||
load("@rules_rs//rs:rust_binary.bzl", "rust_binary")
|
||||
load("@rules_rs//rs:rust_library.bzl", "rust_library")
|
||||
load("@rules_rs//rs:rust_test.bzl", "rust_test")
|
||||
load("@rules_rust//rust:defs.bzl", "rustfmt_test")
|
||||
|
||||
rust_library(
|
||||
name = "openshell-driver-vault",
|
||||
srcs = glob(
|
||||
["src/**/*.rs"],
|
||||
exclude = ["src/main.rs"],
|
||||
),
|
||||
aliases = aliases(),
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True),
|
||||
)
|
||||
|
||||
rust_binary(
|
||||
name = "openshell-driver-vault_bin",
|
||||
srcs = ["src/main.rs"],
|
||||
aliases = aliases(),
|
||||
binary_name = "openshell-driver-vault",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True) + [":openshell-driver-vault"],
|
||||
)
|
||||
|
||||
rust_test(
|
||||
name = "openshell-driver-vault_test",
|
||||
crate = ":openshell-driver-vault",
|
||||
deps = all_crate_deps(normal_dev = True),
|
||||
)
|
||||
|
||||
rustfmt_test(
|
||||
name = "rustfmt_test",
|
||||
targets = [
|
||||
":openshell-driver-vault",
|
||||
":openshell-driver-vault_bin",
|
||||
":openshell-driver-vault_test",
|
||||
],
|
||||
visibility = ["//crates:__pkg__"],
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
load("@crates//:defs.bzl", "aliases", "all_crate_deps")
|
||||
load("@rules_rs//rs:rust_binary.bzl", "rust_binary")
|
||||
load("@rules_rs//rs:rust_library.bzl", "rust_library")
|
||||
load("@rules_rs//rs:rust_test.bzl", "rust_test")
|
||||
load("@rules_rust//rust:defs.bzl", "rustfmt_test")
|
||||
load("@workspace_version//:version.bzl", "WORKSPACE_VERSION")
|
||||
|
||||
VM_RUNTIME = "//bazel/vm-runtime:runtime"
|
||||
|
||||
VM_RUNTIME_ENV = {
|
||||
"OUT_DIR": "$(execpath //bazel/vm-runtime:runtime)",
|
||||
}
|
||||
|
||||
rust_library(
|
||||
name = "openshell-driver-vm",
|
||||
srcs = glob(
|
||||
["src/**/*.rs"],
|
||||
exclude = ["src/main.rs"],
|
||||
),
|
||||
aliases = aliases(),
|
||||
compile_data = [
|
||||
"scripts/openshell-vm-sandbox-init.sh",
|
||||
VM_RUNTIME,
|
||||
],
|
||||
crate_features = ["telemetry"],
|
||||
rustc_env = VM_RUNTIME_ENV,
|
||||
version = WORKSPACE_VERSION,
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True),
|
||||
)
|
||||
|
||||
rust_binary(
|
||||
name = "openshell-driver-vm_bin",
|
||||
srcs = ["src/main.rs"],
|
||||
aliases = aliases(),
|
||||
binary_name = "openshell-driver-vm",
|
||||
version = WORKSPACE_VERSION,
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True) + [":openshell-driver-vm"],
|
||||
)
|
||||
|
||||
rust_test(
|
||||
name = "openshell-driver-vm_lib_test",
|
||||
compile_data = [
|
||||
"scripts/openshell-vm-sandbox-init.sh",
|
||||
VM_RUNTIME,
|
||||
],
|
||||
crate = ":openshell-driver-vm",
|
||||
crate_features = ["telemetry"],
|
||||
rustc_env = VM_RUNTIME_ENV,
|
||||
deps = all_crate_deps(normal_dev = True),
|
||||
)
|
||||
|
||||
rust_test(
|
||||
name = "openshell-driver-vm_bin_test",
|
||||
srcs = ["src/main.rs"],
|
||||
aliases = aliases(),
|
||||
version = WORKSPACE_VERSION,
|
||||
deps = all_crate_deps(
|
||||
normal = True,
|
||||
normal_dev = True,
|
||||
) + [":openshell-driver-vm"],
|
||||
)
|
||||
|
||||
rustfmt_test(
|
||||
name = "rustfmt_test",
|
||||
targets = [
|
||||
":openshell-driver-vm",
|
||||
":openshell-driver-vm_bin",
|
||||
":openshell-driver-vm_bin_test",
|
||||
":openshell-driver-vm_lib_test",
|
||||
],
|
||||
visibility = ["//crates:__pkg__"],
|
||||
)
|
||||
@@ -12,7 +12,10 @@ rust_library(
|
||||
exclude = ["src/main.rs"],
|
||||
),
|
||||
aliases = aliases(),
|
||||
crate_features = ["telemetry"],
|
||||
crate_features = [
|
||||
"bundled-ca-roots",
|
||||
"telemetry",
|
||||
],
|
||||
version = WORKSPACE_VERSION,
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True),
|
||||
@@ -33,7 +36,10 @@ rust_test(
|
||||
name = "openshell-sandbox_lib_test",
|
||||
compile_data = ["//crates/openshell-supervisor-network:sandbox-policy-rego"],
|
||||
crate = ":openshell-sandbox",
|
||||
crate_features = ["telemetry"],
|
||||
crate_features = [
|
||||
"bundled-ca-roots",
|
||||
"telemetry",
|
||||
],
|
||||
deps = all_crate_deps(normal_dev = True),
|
||||
)
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ async-trait = "0.1"
|
||||
url = { workspace = true }
|
||||
glob = { workspace = true }
|
||||
hex = "0.4"
|
||||
russh = "0.61"
|
||||
russh = "0.62"
|
||||
rand = { workspace = true }
|
||||
petname = "2"
|
||||
ipnet = "2"
|
||||
@@ -128,6 +128,8 @@ test-support = []
|
||||
[dev-dependencies]
|
||||
hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "ring"] }
|
||||
rcgen = { version = "0.13", features = ["crypto", "pem"] }
|
||||
rsa = { version = "0.9", features = ["pem"] }
|
||||
base64 = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
futures-util = "0.3"
|
||||
wiremock = "0.6"
|
||||
|
||||
@@ -510,4 +510,256 @@ mod tests {
|
||||
let scopes = claims.extract_scopes("scope");
|
||||
assert!(scopes.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RS256 verification through the real JWKS path
|
||||
//
|
||||
// The tests above only cover claim extraction from an already-trusted
|
||||
// payload. These sign real RS256 tokens and push them through
|
||||
// `JwksCache::new` + `validate_token`, so the JWKS `n`/`e` decoding and the
|
||||
// RSA signature check are exercised against whichever crypto backend
|
||||
// `jsonwebtoken` is built with — a backend swap is otherwise invisible to
|
||||
// the test suite.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const TEST_KID: &str = "test-signing-key";
|
||||
const TEST_AUDIENCE: &str = "openshell-cli";
|
||||
|
||||
/// One RSA key per test binary. Key generation dominates the runtime of
|
||||
/// these tests and the key carries no meaning beyond being valid.
|
||||
static TEST_RSA_KEY: std::sync::LazyLock<TestRsaKey> =
|
||||
std::sync::LazyLock::new(TestRsaKey::generate);
|
||||
|
||||
struct TestRsaKey {
|
||||
private_pem: String,
|
||||
modulus_b64: String,
|
||||
exponent_b64: String,
|
||||
}
|
||||
|
||||
impl TestRsaKey {
|
||||
fn generate() -> Self {
|
||||
use base64::Engine as _;
|
||||
use rsa::pkcs1::EncodeRsaPrivateKey as _;
|
||||
use rsa::traits::PublicKeyParts as _;
|
||||
|
||||
let private = rsa::RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 2048)
|
||||
.expect("generate RSA test key");
|
||||
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
Self {
|
||||
private_pem: private
|
||||
.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)
|
||||
.expect("encode RSA private key as PEM")
|
||||
.to_string(),
|
||||
modulus_b64: b64.encode(private.n().to_bytes_be()),
|
||||
exponent_b64: b64.encode(private.e().to_bytes_be()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
i64::try_from(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock is after the unix epoch")
|
||||
.as_secs(),
|
||||
)
|
||||
.expect("current time fits in i64")
|
||||
}
|
||||
|
||||
/// Sign `claims` with the test key, tagging the header with `kid`.
|
||||
fn mint_rs256(claims: &serde_json::Value, kid: &str) -> String {
|
||||
let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
|
||||
header.kid = Some(kid.to_owned());
|
||||
let key = jsonwebtoken::EncodingKey::from_rsa_pem(TEST_RSA_KEY.private_pem.as_bytes())
|
||||
.expect("load RSA signing key");
|
||||
jsonwebtoken::encode(&header, claims, &key).expect("sign RS256 token")
|
||||
}
|
||||
|
||||
fn claims_for(issuer: &str, audience: &str, exp: i64) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"sub": "user-42",
|
||||
"preferred_username": "ada",
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"exp": exp,
|
||||
"scope": "openid profile sandbox:write",
|
||||
"realm_access": { "roles": ["openshell-user"] },
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve an OIDC discovery document and a JWKS carrying the test key, then
|
||||
/// build a cache against them the same way production does.
|
||||
async fn cache_with_mock_issuer(server: &wiremock::MockServer) -> JwksCache {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, ResponseTemplate};
|
||||
|
||||
let issuer = server.uri();
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/.well-known/openid-configuration"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"issuer": issuer,
|
||||
"jwks_uri": format!("{issuer}/jwks"),
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/jwks"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"keys": [{
|
||||
"kid": TEST_KID,
|
||||
"kty": "RSA",
|
||||
"n": TEST_RSA_KEY.modulus_b64,
|
||||
"e": TEST_RSA_KEY.exponent_b64,
|
||||
}],
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
JwksCache::new(&OidcConfig {
|
||||
issuer,
|
||||
audience: TEST_AUDIENCE.to_owned(),
|
||||
jwks_ttl_secs: 3600,
|
||||
roles_claim: "realm_access.roles".to_owned(),
|
||||
admin_role: "openshell-admin".to_owned(),
|
||||
user_role: "openshell-user".to_owned(),
|
||||
scopes_claim: "scope".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("cache should build from the mock issuer")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rs256_token_signed_by_jwks_key_is_accepted() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let cache = cache_with_mock_issuer(&server).await;
|
||||
|
||||
let token = mint_rs256(
|
||||
&claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600),
|
||||
TEST_KID,
|
||||
);
|
||||
let identity = cache
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.expect("a correctly signed token must be accepted");
|
||||
|
||||
assert_eq!(identity.subject, "user-42");
|
||||
assert_eq!(identity.display_name.as_deref(), Some("ada"));
|
||||
assert_eq!(identity.roles, vec!["openshell-user".to_owned()]);
|
||||
assert_eq!(identity.scopes, vec!["sandbox:write".to_owned()]);
|
||||
assert_eq!(identity.provider, IdentityProvider::Oidc);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rs256_token_with_tampered_payload_is_rejected() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let cache = cache_with_mock_issuer(&server).await;
|
||||
|
||||
let exp = now_secs() + 3600;
|
||||
let token = mint_rs256(&claims_for(&server.uri(), TEST_AUDIENCE, exp), TEST_KID);
|
||||
|
||||
// Keep the header and signature but swap in a payload that escalates
|
||||
// the subject: only the RSA check stands between this and an identity.
|
||||
let segments: Vec<&str> = token.split('.').collect();
|
||||
assert_eq!(segments.len(), 3, "a JWT has three segments");
|
||||
let mut forged_claims = claims_for(&server.uri(), TEST_AUDIENCE, exp);
|
||||
forged_claims["sub"] = serde_json::json!("root");
|
||||
let forged_payload = {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&forged_claims).expect("serialize forged claims"))
|
||||
};
|
||||
let forged = format!("{}.{forged_payload}.{}", segments[0], segments[2]);
|
||||
|
||||
cache
|
||||
.validate_token(&forged)
|
||||
.await
|
||||
.expect_err("a swapped payload must fail the signature check");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rs256_token_signed_by_unrelated_key_is_rejected() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let cache = cache_with_mock_issuer(&server).await;
|
||||
|
||||
let other = TestRsaKey::generate();
|
||||
let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
|
||||
header.kid = Some(TEST_KID.to_owned());
|
||||
let token = jsonwebtoken::encode(
|
||||
&header,
|
||||
&claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600),
|
||||
&jsonwebtoken::EncodingKey::from_rsa_pem(other.private_pem.as_bytes())
|
||||
.expect("load unrelated signing key"),
|
||||
)
|
||||
.expect("sign with unrelated key");
|
||||
|
||||
cache
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.expect_err("a token signed by a key outside the JWKS must be rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rs256_expired_token_is_rejected() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let cache = cache_with_mock_issuer(&server).await;
|
||||
|
||||
// Beyond the 60s default leeway.
|
||||
let token = mint_rs256(
|
||||
&claims_for(&server.uri(), TEST_AUDIENCE, now_secs() - 3600),
|
||||
TEST_KID,
|
||||
);
|
||||
|
||||
cache
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.expect_err("an expired token must be rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rs256_token_from_other_issuer_is_rejected() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let cache = cache_with_mock_issuer(&server).await;
|
||||
|
||||
let token = mint_rs256(
|
||||
&claims_for("https://evil.example.com", TEST_AUDIENCE, now_secs() + 3600),
|
||||
TEST_KID,
|
||||
);
|
||||
|
||||
cache
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.expect_err("a token from another issuer must be rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rs256_token_for_other_audience_is_rejected() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let cache = cache_with_mock_issuer(&server).await;
|
||||
|
||||
let token = mint_rs256(
|
||||
&claims_for(&server.uri(), "some-other-client", now_secs() + 3600),
|
||||
TEST_KID,
|
||||
);
|
||||
|
||||
cache
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.expect_err("a token minted for another audience must be rejected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rs256_token_with_unknown_kid_is_rejected() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let cache = cache_with_mock_issuer(&server).await;
|
||||
|
||||
let token = mint_rs256(
|
||||
&claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600),
|
||||
"rotated-away-key",
|
||||
);
|
||||
|
||||
cache
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.expect_err("a token naming a kid absent from the JWKS must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ use tonic::{Request, Response, Status};
|
||||
use crate::MIDDLEWARE_GRPC_MESSAGE_BYTES;
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const HTTP2_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
const HTTP2_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteMiddlewareService {
|
||||
@@ -30,7 +32,12 @@ impl RemoteMiddlewareService {
|
||||
format!(
|
||||
"middleware registration '{registration_name}' has an invalid grpc_endpoint"
|
||||
)
|
||||
})?;
|
||||
})?
|
||||
.http2_keep_alive_interval(HTTP2_KEEP_ALIVE_INTERVAL)
|
||||
.keep_alive_while_idle(true)
|
||||
.keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT)
|
||||
.http2_adaptive_window(true);
|
||||
|
||||
if grpc_endpoint.starts_with("https://") {
|
||||
endpoint = endpoint
|
||||
.tls_config(ClientTlsConfig::new().with_enabled_roots())
|
||||
@@ -39,6 +46,7 @@ impl RemoteMiddlewareService {
|
||||
format!("middleware registration '{registration_name}' could not configure TLS")
|
||||
})?;
|
||||
}
|
||||
|
||||
let channel = endpoint
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.connect()
|
||||
@@ -49,6 +57,7 @@ impl RemoteMiddlewareService {
|
||||
"middleware registration '{registration_name}' could not connect to {grpc_endpoint}"
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
client: SupervisorMiddlewareClient::new(channel)
|
||||
.max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES)
|
||||
|
||||
@@ -14,6 +14,7 @@ rust_library(
|
||||
srcs = glob(["src/**/*.rs"]),
|
||||
aliases = aliases(),
|
||||
compile_data = glob(["data/**/*"]),
|
||||
crate_features = ["bundled-ca-roots"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True),
|
||||
)
|
||||
@@ -22,6 +23,7 @@ rust_test(
|
||||
name = "openshell-supervisor-network_test",
|
||||
compile_data = glob(["testdata/**/*"]),
|
||||
crate = ":openshell-supervisor-network",
|
||||
crate_features = ["bundled-ca-roots"],
|
||||
deps = all_crate_deps(normal_dev = True),
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ hex = "0.4"
|
||||
miette = { workspace = true }
|
||||
nix = { workspace = true }
|
||||
rand = "0.10"
|
||||
russh = "0.61"
|
||||
russh = "0.62"
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -20,9 +20,9 @@ use openshell_core::provider_credentials::ProviderCredentialState;
|
||||
use openshell_ocsf::{
|
||||
ActionId, ActivityId, DispositionId, SeverityId, SshActivityBuilder, StatusId, ocsf_emit,
|
||||
};
|
||||
use russh::ChannelId;
|
||||
use russh::keys::{Algorithm, PrivateKey};
|
||||
use russh::server::{Auth, Handle, Session};
|
||||
use russh::server::{Auth, ChannelOpenHandle, Handle, Session};
|
||||
use russh::{ChannelId, ChannelOpenFailure};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::{AsRawFd, RawFd};
|
||||
@@ -297,10 +297,12 @@ impl russh::server::Handler for SshHandler {
|
||||
async fn channel_open_session(
|
||||
&mut self,
|
||||
channel: russh::Channel<russh::server::Msg>,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut Session,
|
||||
) -> Result<bool, Self::Error> {
|
||||
) -> Result<(), Self::Error> {
|
||||
self.channels.insert(channel.id(), ChannelState::default());
|
||||
Ok(true)
|
||||
reply.accept().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up per-channel state when the channel is closed.
|
||||
@@ -324,8 +326,9 @@ impl russh::server::Handler for SshHandler {
|
||||
port_to_connect: u32,
|
||||
_originator_address: &str,
|
||||
_originator_port: u32,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut Session,
|
||||
) -> Result<bool, Self::Error> {
|
||||
) -> Result<(), Self::Error> {
|
||||
// Validate port range before truncating u32 -> u16. The SSH protocol
|
||||
// uses u32 for ports, but valid TCP ports are 0-65535. Without this
|
||||
// check, port 65537 truncates to port 1 (privileged).
|
||||
@@ -339,7 +342,10 @@ impl russh::server::Handler for SshHandler {
|
||||
"direct-tcpip rejected: port {port_to_connect} exceeds valid TCP range for host {host_to_connect}"
|
||||
))
|
||||
.build());
|
||||
return Ok(false);
|
||||
reply
|
||||
.reject(ChannelOpenFailure::AdministrativelyProhibited)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Only allow forwarding to loopback destinations to prevent the
|
||||
@@ -354,7 +360,10 @@ impl russh::server::Handler for SshHandler {
|
||||
"direct-tcpip rejected: non-loopback destination {host_to_connect}:{port_to_connect}"
|
||||
))
|
||||
.build());
|
||||
return Ok(false);
|
||||
reply
|
||||
.reject(ChannelOpenFailure::AdministrativelyProhibited)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let host = host_to_connect.to_string();
|
||||
@@ -363,6 +372,10 @@ impl russh::server::Handler for SshHandler {
|
||||
let port = u16::try_from(port_to_connect).unwrap_or(u16::MAX);
|
||||
let netns_fd = self.netns_fd;
|
||||
|
||||
// Confirm the channel before spawning: the task below writes to it, and
|
||||
// the peer must see the open-confirmation first.
|
||||
reply.accept().await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let addr = format!("{host}:{port}");
|
||||
let tcp = match connect_in_netns(&addr, netns_fd).await {
|
||||
@@ -387,7 +400,7 @@ impl russh::server::Handler for SshHandler {
|
||||
let _ = tokio::io::copy_bidirectional(&mut channel_stream, &mut tcp_stream).await;
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pty_request(
|
||||
@@ -1951,4 +1964,178 @@ mod tests {
|
||||
"resolved-identity-ok"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// direct-tcpip authorization wiring (SEC-007)
|
||||
//
|
||||
// The `loopback_host_*` tests above cover the predicate in isolation.
|
||||
// These drive the real `russh::server::Handler` over an in-memory duplex
|
||||
// so the deny path itself is covered: channel-open authorization travels
|
||||
// through a reply handle rather than the handler's return value, so a
|
||||
// handler that never rejects anything still type-checks and still passes
|
||||
// every predicate test.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
struct AcceptAnyServerKey;
|
||||
|
||||
impl russh::client::Handler for AcceptAnyServerKey {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_server_public_key: &russh::keys::PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn forwarding_test_policy() -> SandboxPolicy {
|
||||
use openshell_core::policy::{
|
||||
FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy,
|
||||
};
|
||||
|
||||
SandboxPolicy {
|
||||
version: 0,
|
||||
filesystem: FilesystemPolicy::default(),
|
||||
network: NetworkPolicy::default(),
|
||||
landlock: LandlockPolicy::default(),
|
||||
process: ProcessPolicy {
|
||||
run_as_user: None,
|
||||
run_as_group: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve `SshHandler` on one end of an in-memory duplex and return an
|
||||
/// authenticated client handle for the other end.
|
||||
///
|
||||
/// The handler gets `netns_fd: None` so `connect_in_netns` performs a plain
|
||||
/// TCP connect, making the forwarding path reachable without a network
|
||||
/// namespace.
|
||||
async fn authenticated_test_client() -> russh::client::Handle<AcceptAnyServerKey> {
|
||||
// Scoped so the `!Send` ThreadRng is dropped before the first await.
|
||||
let host_key = {
|
||||
let mut rng = rand::rng();
|
||||
PrivateKey::random(&mut rng, Algorithm::Ed25519).expect("host key")
|
||||
};
|
||||
let mut server_config = russh::server::Config {
|
||||
auth_rejection_time: Duration::from_millis(1),
|
||||
..Default::default()
|
||||
};
|
||||
server_config.keys.push(host_key);
|
||||
|
||||
let handler = SshHandler::new(
|
||||
forwarding_test_policy(),
|
||||
ResolvedWorkspace::default(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
ProviderCredentialState::from_child_env_snapshot(0, HashMap::new()),
|
||||
HashMap::new(),
|
||||
ResolvedProcessIdentity::default(),
|
||||
ProcessEnforcementMode::NetworkOnly,
|
||||
);
|
||||
|
||||
let (server_stream, client_stream) = tokio::io::duplex(64 * 1024);
|
||||
tokio::spawn(async move {
|
||||
if let Ok(session) =
|
||||
russh::server::run_stream(Arc::new(server_config), server_stream, handler).await
|
||||
{
|
||||
let _ = session.await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut client = russh::client::connect_stream(
|
||||
Arc::new(russh::client::Config::default()),
|
||||
client_stream,
|
||||
AcceptAnyServerKey,
|
||||
)
|
||||
.await
|
||||
.expect("SSH handshake should complete over the duplex");
|
||||
|
||||
let auth = client
|
||||
.authenticate_none("sandbox")
|
||||
.await
|
||||
.expect("auth_none should not error");
|
||||
assert!(
|
||||
matches!(auth, russh::client::AuthResult::Success),
|
||||
"sandbox SSH server accepts the none auth method"
|
||||
);
|
||||
|
||||
client
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_tcpip_rejects_non_loopback_destination() {
|
||||
let client = authenticated_test_client().await;
|
||||
|
||||
let err = client
|
||||
.channel_open_direct_tcpip("10.0.0.1", 80, "127.0.0.1", 0)
|
||||
.await
|
||||
.expect_err("forwarding to a non-loopback host must be refused");
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
russh::Error::ChannelOpenFailure(ChannelOpenFailure::AdministrativelyProhibited)
|
||||
),
|
||||
"expected AdministrativelyProhibited, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_tcpip_rejects_port_above_tcp_range() {
|
||||
let client = authenticated_test_client().await;
|
||||
|
||||
// 65_537 truncates to port 1 when cast to u16, so the guard has to
|
||||
// reject it before the cast rather than forward to a privileged port.
|
||||
let err = client
|
||||
.channel_open_direct_tcpip("127.0.0.1", 65_537, "127.0.0.1", 0)
|
||||
.await
|
||||
.expect_err("a port outside the TCP range must be refused");
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
russh::Error::ChannelOpenFailure(ChannelOpenFailure::AdministrativelyProhibited)
|
||||
),
|
||||
"expected AdministrativelyProhibited, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_tcpip_forwards_to_loopback_listener() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind loopback echo listener");
|
||||
let port = listener.local_addr().expect("listener address").port();
|
||||
tokio::spawn(async move {
|
||||
if let Ok((mut socket, _)) = listener.accept().await {
|
||||
let mut buf = [0u8; 64];
|
||||
if let Ok(n) = socket.read(&mut buf).await
|
||||
&& n > 0
|
||||
{
|
||||
let _ = socket.write_all(&buf[..n]).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let client = authenticated_test_client().await;
|
||||
let channel = client
|
||||
.channel_open_direct_tcpip("127.0.0.1", u32::from(port), "127.0.0.1", 0)
|
||||
.await
|
||||
.expect("forwarding to a loopback listener must be allowed");
|
||||
|
||||
let mut stream = channel.into_stream();
|
||||
stream.write_all(b"ping").await.expect("write to channel");
|
||||
|
||||
let mut echoed = [0u8; 4];
|
||||
tokio::time::timeout(Duration::from_secs(10), stream.read_exact(&mut echoed))
|
||||
.await
|
||||
.expect("relayed response should arrive before the timeout")
|
||||
.expect("read from channel");
|
||||
assert_eq!(&echoed, b"ping", "bytes round-trip through the tunnel");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ rust_library(
|
||||
srcs = glob(["src/**/*.rs"]),
|
||||
aliases = aliases(),
|
||||
target_compatible_with = ["@platforms//os:linux"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = all_crate_deps(normal = True),
|
||||
)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -114,8 +114,10 @@ reflection, inference-route management, and HTTP requests. A
|
||||
is expected for those requests. The gateway fails startup if a callback
|
||||
requirement resolves to the exact primary listener address because one socket
|
||||
cannot preserve both authorization scopes. For the IPv4-loopback callback used
|
||||
by Podman Machine, bind the primary listener to a distinct address such as
|
||||
`[::1]:17670`.
|
||||
by Podman Machine, set `bind_address = "[::1]:17670"` for the primary listener
|
||||
and register `https://localhost:17670` as the CLI endpoint. The hostname matches
|
||||
the generated certificate and avoids the TLS transport error produced by a raw
|
||||
IPv6-literal endpoint. Do not broaden the primary listener to `0.0.0.0`.
|
||||
|
||||
## Docker Driver
|
||||
|
||||
|
||||
+22
-10
@@ -386,6 +386,16 @@ export OPENSHELL_E2E_GATEWAY_CA_CERT="${PKI_DIR}/ca.crt"
|
||||
|
||||
HOST_PORT=$(e2e_pick_port)
|
||||
HEALTH_PORT=$(e2e_pick_port)
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
# Podman Machine reserves IPv4 loopback for its callback-only listener.
|
||||
PRIMARY_BIND_IP="::1"
|
||||
CLI_ENDPOINT_HOST="localhost"
|
||||
HEALTH_ENDPOINT_HOST="[::1]"
|
||||
else
|
||||
PRIMARY_BIND_IP="127.0.0.1"
|
||||
CLI_ENDPOINT_HOST="127.0.0.1"
|
||||
HEALTH_ENDPOINT_HOST="127.0.0.1"
|
||||
fi
|
||||
STATE_DIR="${WORKDIR}/state"
|
||||
mkdir -p "${STATE_DIR}"
|
||||
export XDG_STATE_HOME="${STATE_DIR}"
|
||||
@@ -415,11 +425,11 @@ toml_string() {
|
||||
|
||||
GATEWAY_CONFIG="${STATE_DIR}/gateway.toml"
|
||||
|
||||
# Start from the RPM default template so this e2e test exercises the same
|
||||
# TOML config path that RPM users get on first start. The template leaves
|
||||
# bind_address unset and sets compute_drivers = ["podman"], so this test
|
||||
# exercises the built-in loopback listener plus the callback listener
|
||||
# requested by the Podman driver.
|
||||
# Start from the RPM default template so this e2e test exercises the same TOML
|
||||
# config path that RPM users get on first start. The template leaves
|
||||
# bind_address unset and sets compute_drivers = ["podman"]. On Podman Machine,
|
||||
# the driver reserves IPv4 loopback for its callback-only listener, so the
|
||||
# primary listener uses IPv6 loopback. Native Linux keeps the IPv4 default.
|
||||
#
|
||||
# We append the driver-specific table and override the port via CLI flag
|
||||
# (CLI > TOML in the merge precedence) so the test can use an ephemeral port.
|
||||
@@ -458,8 +468,9 @@ cp "${ROOT}/deploy/rpm/gateway.toml.default" "${GATEWAY_CONFIG}"
|
||||
|
||||
GATEWAY_ARGS=(
|
||||
--config "${GATEWAY_CONFIG}"
|
||||
# compute_drivers comes from the RPM template, while bind_address uses the
|
||||
# built-in loopback default. Override only the port for ephemeral selection.
|
||||
# compute_drivers comes from the RPM template. Override the loopback address
|
||||
# and port so Podman Machine can keep its IPv4 callback listener distinct.
|
||||
--bind-address "${PRIMARY_BIND_IP}"
|
||||
--port "${HOST_PORT}"
|
||||
--health-port "${HEALTH_PORT}"
|
||||
--tls-cert "${PKI_DIR}/server/tls.crt"
|
||||
@@ -495,10 +506,10 @@ printf '%s\n' "${GATEWAY_PID}" >"${GATEWAY_PID_FILE}"
|
||||
|
||||
GATEWAY_NAME="openshell-e2e-podman-${HOST_PORT}"
|
||||
if [ "${OIDC_MODE}" = "1" ]; then
|
||||
CLI_GATEWAY_ENDPOINT="https://127.0.0.1:${HOST_PORT}"
|
||||
CLI_GATEWAY_ENDPOINT="https://${CLI_ENDPOINT_HOST}:${HOST_PORT}"
|
||||
export OPENSHELL_E2E_OIDC_GATEWAY_ENDPOINT="${CLI_GATEWAY_ENDPOINT}"
|
||||
else
|
||||
CLI_GATEWAY_ENDPOINT="https://127.0.0.1:${HOST_PORT}"
|
||||
CLI_GATEWAY_ENDPOINT="https://${CLI_ENDPOINT_HOST}:${HOST_PORT}"
|
||||
e2e_register_mtls_gateway \
|
||||
"${XDG_CONFIG_HOME}" \
|
||||
"${GATEWAY_NAME}" \
|
||||
@@ -524,7 +535,8 @@ while [ "${elapsed}" -lt "${timeout}" ]; do
|
||||
echo "ERROR: openshell-gateway exited before becoming healthy"
|
||||
exit 1
|
||||
fi
|
||||
if curl -sf "http://127.0.0.1:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then
|
||||
# Keep this loopback probe direct even when ::1 is absent from NO_PROXY.
|
||||
if curl --noproxy '*' -sf "http://${HEALTH_ENDPOINT_HOST}:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then
|
||||
echo "Gateway healthy after ${elapsed}s."
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools.buf]]
|
||||
version = "1.72.0"
|
||||
backend = "aqua:bufbuild/buf"
|
||||
|
||||
[tools.buf."platforms.linux-arm64"]
|
||||
checksum = "sha256:7641bd7e06a37a54cbb8c789f53465899def96196ab5c08057432f781a15d517"
|
||||
url = "https://github.com/bufbuild/buf/releases/download/v1.72.0/buf-Linux-aarch64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/bufbuild/buf/releases/assets/480772526"
|
||||
provenance = "minisign"
|
||||
|
||||
[tools.buf."platforms.linux-x64"]
|
||||
checksum = "sha256:a9c6186cf6fcf062b247345e1b7b12c26f580c1b2a4bbf4d3fe080abf85ceee8"
|
||||
url = "https://github.com/bufbuild/buf/releases/download/v1.72.0/buf-Linux-x86_64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/bufbuild/buf/releases/assets/480772583"
|
||||
provenance = "minisign"
|
||||
|
||||
[tools.buf."platforms.macos-arm64"]
|
||||
checksum = "sha256:be040ae0ca381103dfda68a36738695c4db3e48de8e91412acdc3d991f39b91e"
|
||||
url = "https://github.com/bufbuild/buf/releases/download/v1.72.0/buf-Darwin-arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/bufbuild/buf/releases/assets/480772487"
|
||||
provenance = "minisign"
|
||||
|
||||
[[tools."github:EmbarkStudios/cargo-about"]]
|
||||
version = "0.8.4"
|
||||
backend = "github:EmbarkStudios/cargo-about"
|
||||
@@ -104,6 +126,38 @@ checksum = "sha256:29caf036bdbb4e6f07afea31706b6f386cb5a4db9a46a3a8b462b9b78157e
|
||||
url = "https://github.com/rust-cross/cargo-zigbuild/releases/download/v0.22.3/cargo-zigbuild-aarch64-apple-darwin.tar.xz"
|
||||
url_api = "https://api.github.com/repos/rust-cross/cargo-zigbuild/releases/assets/405676922"
|
||||
|
||||
[[tools.go]]
|
||||
version = "1.26.5"
|
||||
backend = "core:go"
|
||||
|
||||
[tools.go."platforms.linux-arm64"]
|
||||
checksum = "sha256:fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49"
|
||||
url = "https://dl.google.com/go/go1.26.5.linux-arm64.tar.gz"
|
||||
|
||||
[tools.go."platforms.linux-x64"]
|
||||
checksum = "sha256:5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053"
|
||||
url = "https://dl.google.com/go/go1.26.5.linux-amd64.tar.gz"
|
||||
|
||||
[tools.go."platforms.macos-arm64"]
|
||||
checksum = "sha256:efb87ff28af9a188d0536ef5d42e63dd52ba8263cd7344a993cc48dd11dedb6a"
|
||||
url = "https://dl.google.com/go/go1.26.5.darwin-arm64.tar.gz"
|
||||
|
||||
[[tools."go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint"]]
|
||||
version = "2.12.2"
|
||||
backend = "go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint"
|
||||
|
||||
[[tools."go:golang.org/x/tools/cmd/goimports"]]
|
||||
version = "0.48.0"
|
||||
backend = "go:golang.org/x/tools/cmd/goimports"
|
||||
|
||||
[[tools."go:google.golang.org/grpc/cmd/protoc-gen-go-grpc"]]
|
||||
version = "1.6.2"
|
||||
backend = "go:google.golang.org/grpc/cmd/protoc-gen-go-grpc"
|
||||
|
||||
[[tools."go:google.golang.org/protobuf/cmd/protoc-gen-go"]]
|
||||
version = "1.36.11"
|
||||
backend = "go:google.golang.org/protobuf/cmd/protoc-gen-go"
|
||||
|
||||
[[tools.helm]]
|
||||
version = "4.2.0"
|
||||
backend = "aqua:helm/helm"
|
||||
@@ -127,14 +181,17 @@ backend = "aqua:norwoodj/helm-docs"
|
||||
[tools.helm-docs."platforms.linux-arm64"]
|
||||
checksum = "sha256:c3787212332386dcd122debef7848feb165aa701467ae3e3442df7638f3ac4e4"
|
||||
url = "https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/helm-docs_1.14.2_Linux_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/norwoodj/helm-docs/releases/assets/178327216"
|
||||
|
||||
[tools.helm-docs."platforms.linux-x64"]
|
||||
checksum = "sha256:a8cf72ada34fad93285ba2a452b38bdc5bd52cc9a571236244ec31022928d6cc"
|
||||
url = "https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/helm-docs_1.14.2_Linux_x86_64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/norwoodj/helm-docs/releases/assets/178327210"
|
||||
|
||||
[tools.helm-docs."platforms.macos-arm64"]
|
||||
checksum = "sha256:2d8399db5b33d240d5f8985241bcf5483563150b968e3229823822979f3e4b8b"
|
||||
url = "https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/helm-docs_1.14.2_Darwin_arm64.tar.gz"
|
||||
url_api = "https://api.github.com/repos/norwoodj/helm-docs/releases/assets/178327215"
|
||||
|
||||
[[tools.k3d]]
|
||||
version = "5.8.3"
|
||||
@@ -143,14 +200,17 @@ backend = "aqua:k3d-io/k3d"
|
||||
[tools.k3d."platforms.linux-arm64"]
|
||||
checksum = "sha256:0b8110f2229631af7402fb828259330985918b08fefd38b7f1b788a1c8687216"
|
||||
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-linux-arm64"
|
||||
url_api = "https://api.github.com/repos/k3d-io/k3d/releases/assets/229450023"
|
||||
|
||||
[tools.k3d."platforms.linux-x64"]
|
||||
checksum = "sha256:dbaa79a76ace7f4ca230a1ff41dc7d8a5036a8ad0309e9c54f9bf3836dbe853e"
|
||||
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-linux-amd64"
|
||||
url_api = "https://api.github.com/repos/k3d-io/k3d/releases/assets/229450045"
|
||||
|
||||
[tools.k3d."platforms.macos-arm64"]
|
||||
checksum = "sha256:8da468daa7dc7cf7cdd4735f90a9bb05179fa27858250f62e3d8cdf5b5ca0698"
|
||||
url = "https://github.com/k3d-io/k3d/releases/download/v5.8.3/k3d-darwin-arm64"
|
||||
url_api = "https://api.github.com/repos/k3d-io/k3d/releases/assets/229450067"
|
||||
|
||||
[[tools.kubectl]]
|
||||
version = "1.36.1"
|
||||
@@ -195,14 +255,17 @@ backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||
[tools.protoc."platforms.linux-arm64"]
|
||||
checksum = "sha256:2594ff4fcae8cb57310d394d0961b236190ad9c5efbfdf1f597ea471d424fe79"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-aarch_64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795076"
|
||||
|
||||
[tools.protoc."platforms.linux-x64"]
|
||||
checksum = "sha256:48785a926e73ffa3f68e2f22b14e7b849620c7a1d36809ac9249a5495e280323"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-linux-x86_64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795083"
|
||||
|
||||
[tools.protoc."platforms.macos-arm64"]
|
||||
checksum = "sha256:b9576b5fa1a1ef3fe13a8c91d9d8204b46545759bea5ae155cd6ba2ea4cdaeed"
|
||||
url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.6/protoc-29.6-osx-aarch_64.zip"
|
||||
url_api = "https://api.github.com/repos/protocolbuffers/protobuf/releases/assets/350795082"
|
||||
|
||||
[[tools.python]]
|
||||
version = "3.14.5"
|
||||
@@ -251,16 +314,19 @@ backend = "aqua:astral-sh/uv"
|
||||
[tools.uv."platforms.linux-arm64"]
|
||||
checksum = "sha256:55bd1c1c10ec8b95a8c184f5e18b566703c6ab105f0fc118aaa4d748aabf28e4"
|
||||
url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-unknown-linux-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491942"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.uv."platforms.linux-x64"]
|
||||
checksum = "sha256:adccf40b5d1939a5e0093081ec2307ea24235adf7c2d96b122c561fa37711c46"
|
||||
url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-x86_64-unknown-linux-musl.tar.gz"
|
||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491998"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[tools.uv."platforms.macos-arm64"]
|
||||
checksum = "sha256:ae738b5661a900579ec621d3918c0ef17bdec0da2a8a6d8b161137cd15f25414"
|
||||
url = "https://github.com/astral-sh/uv/releases/download/0.10.12/uv-aarch64-apple-darwin.tar.gz"
|
||||
url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/377491929"
|
||||
provenance = "github-attestations"
|
||||
|
||||
[[tools.zig]]
|
||||
|
||||
@@ -25,6 +25,12 @@ node = "24.15.0"
|
||||
kubectl = "1.36.1"
|
||||
uv = "0.10.12"
|
||||
protoc = "29.6"
|
||||
go = "1.26"
|
||||
"go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint" = "2.12"
|
||||
"go:google.golang.org/protobuf/cmd/protoc-gen-go" = "1.36.11"
|
||||
"go:google.golang.org/grpc/cmd/protoc-gen-go-grpc" = "1.6.2"
|
||||
"go:golang.org/x/tools/cmd/goimports" = "0.48.0"
|
||||
buf = "1.72.0"
|
||||
helm = "4.2.0"
|
||||
helm-docs = "1.14.2"
|
||||
skaffold = "2.20.0"
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
---
|
||||
authors:
|
||||
- "@johntmyers"
|
||||
state: review
|
||||
links:
|
||||
- https://github.com/NVIDIA/OpenShell/issues/1107
|
||||
- https://github.com/NVIDIA/OpenShell/pull/2155
|
||||
- https://github.com/NVIDIA/OpenShell/pull/1083
|
||||
- https://github.com/NVIDIA/OpenShell/pull/1151
|
||||
- https://github.com/NVIDIA/OpenShell/pull/1286
|
||||
- https://github.com/NVIDIA/OpenShell/pull/1511
|
||||
- https://github.com/NVIDIA/OpenShell/pull/1738
|
||||
- https://github.com/NVIDIA/OpenShell/pull/2027
|
||||
- https://github.com/NVIDIA/OpenShell/pull/1865
|
||||
- https://github.com/NVIDIA/OpenShell/pull/1938
|
||||
---
|
||||
|
||||
# RFC 0005 - Sandbox Proxy Egress Adapter Model
|
||||
|
||||
<!--
|
||||
See rfc/README.md for the full RFC process and state definitions.
|
||||
-->
|
||||
|
||||
## Summary
|
||||
|
||||
Refactor sandbox egress around shared authorization, destination-validation,
|
||||
and relay boundaries. CONNECT, forward HTTP, native TCP capture, policy DNS,
|
||||
`inference.local`, `policy.local`, and metadata loopback become narrow adapters
|
||||
that translate userland entry points into common runtime intents. Policy
|
||||
evaluation, destination validation, supervisor middleware, credential
|
||||
injection, request-body rewrite, WebSocket handling, protocol processing, and
|
||||
upstream dialing happen behind shared boundaries.
|
||||
|
||||
The RFC describes the complete forward-looking architecture. It is designed
|
||||
to land incrementally across multiple pull requests. The first milestone only
|
||||
restructures CONNECT and forward HTTP, extracts shared primitives, and
|
||||
preserves every current user-facing feature and enforcement behavior. Later
|
||||
milestones add policy DNS, transparent TCP capture, native protocol processors,
|
||||
and optional deployment shapes on top of those boundaries.
|
||||
|
||||
The codebase has already moved in this direction by splitting networking into
|
||||
`openshell-supervisor-network` and process/netns work into
|
||||
`openshell-supervisor-process`. This RFC proposes the next internal boundary:
|
||||
make proxy entry mechanisms pluggable without duplicating authorization,
|
||||
destination validation, or relay behavior.
|
||||
|
||||
Supporting detail lives in:
|
||||
|
||||
- [Current shape appendix](current-shape.md)
|
||||
- [Technical design appendix](technical-design.md)
|
||||
- [Implementation plan](implementation-plan.md)
|
||||
|
||||
## Motivation
|
||||
|
||||
The sandbox proxy supports several connection surfaces: explicit CONNECT,
|
||||
forward HTTP, local inference and policy APIs, metadata loopback, TLS
|
||||
termination, REST, GraphQL, JSON-RPC, MCP, and WebSocket inspection,
|
||||
credential injection, supervisor middleware, and nftables-backed bypass
|
||||
detection. These features are valuable, but changes to policy and enforcement
|
||||
still tend to touch multiple entry paths.
|
||||
|
||||
The risk is asymmetric enforcement. A security fix can be added to CONNECT and
|
||||
missed in forward HTTP; endpoint metadata can be selected differently from the
|
||||
logged policy; a credential path can gain request-body or WebSocket support
|
||||
without the same behavior existing in another relay mode.
|
||||
|
||||
The target shape separates three concerns:
|
||||
|
||||
- **Adapters** describe how userland reached the networking component.
|
||||
- **Authorization** decides whether the egress is allowed and what endpoint
|
||||
behavior applies.
|
||||
- **Relays** own bytes, credentials, protocol parsing, and upstream dialing.
|
||||
|
||||
The first milestone targets the current embedded/network-only supervisor
|
||||
runtime and preserves its existing user-facing behavior while the internal
|
||||
seams move. The same boundaries then support policy DNS and transparent TCP,
|
||||
native protocol processing, and future deployment modes without duplicating
|
||||
authorization or relay logic.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Replace CONNECT with forward proxy as the only explicit proxy mode.
|
||||
- Add SOCKS support.
|
||||
- Add HTTP/2 L7 parsing in this refactor. Inspected HTTP paths should continue
|
||||
to reject unsupported h2c upgrades instead of silently upgrading to raw
|
||||
traffic.
|
||||
- Redesign provider credential storage.
|
||||
- Reintroduce iptables as the sandbox packet filtering backend.
|
||||
- Use eBPF connect hooks for transparent capture. Native TCP capture needs a
|
||||
userland proxy in the byte stream for TLS termination and protocol parsing.
|
||||
- Add policy-declared supervisor-proxied host-local endpoints. Issue
|
||||
[#1633](https://github.com/NVIDIA/OpenShell/issues/1633) can consume these
|
||||
boundaries in separate feature work.
|
||||
- Change the existing endpoint-only runtime's process-identity semantics during
|
||||
the compatibility refactor. Future identity-less deployment modes require an
|
||||
explicit capability contract and cannot inherit endpoint-only behavior by
|
||||
accident.
|
||||
|
||||
## Proposal
|
||||
|
||||
### Migration Big Rocks
|
||||
|
||||
1. **Transport and local-service adapters.** CONNECT, forward HTTP,
|
||||
transparent TCP, policy DNS, `inference.local`, `policy.local`, and metadata
|
||||
loopback become small adapters. They parse their surface and produce either
|
||||
an egress intent, a local response, or a DNS answer. They do not duplicate
|
||||
policy evaluation.
|
||||
2. **Egress intent and decision.** Shared authorization evaluates L4 policy and
|
||||
endpoint selection once per connection intent and returns one decision
|
||||
containing the matched policy, matched endpoint, optional process identity
|
||||
evidence used for evaluation, allowed IP metadata, TLS behavior, protocol
|
||||
enforcement, and credential injection and middleware plans.
|
||||
3. **Relays.** Relays receive an authorized destination connector, not an
|
||||
already-open upstream socket. HTTP relays evaluate every request before
|
||||
upstream write. TCP relays copy bytes for L4-only endpoints or hand the
|
||||
stream to a protocol processor when endpoint policy requires native protocol
|
||||
enforcement.
|
||||
|
||||
The first implementation milestone populates a compatibility
|
||||
`EgressDecision` through the existing separate queries so type extraction does
|
||||
not change behavior. That transitional envelope is not the target single
|
||||
authorization result. Generation-consistent materialization and deterministic
|
||||
endpoint selection cut over separately after shadow comparison, before later
|
||||
transport adapters depend on the result.
|
||||
|
||||
### Unified Adapter Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User["Userland payload / harness"]
|
||||
|
||||
subgraph ExplicitProxy["Explicit proxy listener"]
|
||||
ProxyBytes["HTTP proxy bytes"]
|
||||
IsConnect{"CONNECT request?"}
|
||||
Connect["CONNECT adapter"]
|
||||
Forward["Forward HTTP adapter"]
|
||||
ProxyBytes --> IsConnect
|
||||
IsConnect -- Yes --> Connect
|
||||
IsConnect -- No --> Forward
|
||||
end
|
||||
|
||||
subgraph NativeTcp["Policy DNS + native TCP"]
|
||||
NameLookup["Userland DNS lookup"]
|
||||
PolicyDns["Policy DNS adapter"]
|
||||
DnsEligible{"Eligible native TCP<br/>policy endpoint?"}
|
||||
DnsDeny["Local DNS refusal<br/>no upstream lookup"]
|
||||
TrustedDns["Trusted upstream lookup<br/>and destination filtering"]
|
||||
DnsMapping["Synthetic IP + active mapping<br/>to validated real addresses"]
|
||||
DnsAnswer["Return synthetic IP"]
|
||||
NativeConnect["Userland connect(synthetic_ip:port)"]
|
||||
TcpAdapter["Transparent TCP adapter<br/>recover mapping"]
|
||||
NameLookup --> PolicyDns
|
||||
PolicyDns --> DnsEligible
|
||||
DnsEligible -- No --> DnsDeny
|
||||
DnsEligible -- Yes --> TrustedDns
|
||||
TrustedDns --> DnsMapping
|
||||
DnsMapping --> DnsAnswer
|
||||
DnsAnswer --> NativeConnect
|
||||
NativeConnect --> TcpAdapter
|
||||
end
|
||||
|
||||
subgraph LocalApis["Sandbox-local services"]
|
||||
InferenceReq["Request to inference.local"]
|
||||
PolicyReq["Request to policy.local"]
|
||||
MetadataReq["Request to metadata loopback"]
|
||||
InferenceAdapter["Inference local adapter"]
|
||||
PolicyAdapter["Policy local adapter"]
|
||||
MetadataAdapter["Metadata loopback adapter"]
|
||||
InferenceReq --> InferenceAdapter
|
||||
PolicyReq --> PolicyAdapter
|
||||
MetadataReq --> MetadataAdapter
|
||||
end
|
||||
|
||||
subgraph Shared["Shared external egress pipeline"]
|
||||
Intent["EgressIntent"]
|
||||
Auth["Authorize and select endpoint"]
|
||||
Decision["EgressDecision"]
|
||||
Validate["Resolve or consume pinned destination<br/>and validate"]
|
||||
Relay["Relay"]
|
||||
Deny["Adapter-specific deny response"]
|
||||
Intent --> Auth
|
||||
Auth --> Allowed{"Allowed?"}
|
||||
Allowed -- No --> Deny
|
||||
Allowed -- Yes --> Decision
|
||||
Decision --> Validate
|
||||
Validate --> Relay
|
||||
end
|
||||
|
||||
User --> ProxyBytes
|
||||
User --> NameLookup
|
||||
User --> NativeConnect
|
||||
User --> InferenceReq
|
||||
User --> PolicyReq
|
||||
User --> MetadataReq
|
||||
|
||||
Connect --> Intent
|
||||
Forward --> Intent
|
||||
TcpAdapter --> Intent
|
||||
InferenceAdapter --> InferenceResp["Local inference response"]
|
||||
PolicyAdapter --> PolicyResp["Local policy response"]
|
||||
MetadataAdapter --> MetadataResp["Local metadata credential response"]
|
||||
```
|
||||
|
||||
Each adapter still owns its response shape. If authorization denies a CONNECT
|
||||
intent, the CONNECT adapter returns a tunnel denial. If forward HTTP is denied,
|
||||
the forward adapter returns an HTTP denial. If policy DNS refuses a name, it
|
||||
returns the appropriate DNS response. The shared layer decides the outcome;
|
||||
the adapter renders it for its protocol.
|
||||
|
||||
### Relay Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start["Authorized egress + destination connector"]
|
||||
Start --> FirstReq{"Forward HTTP adapter<br/>already has first request?"}
|
||||
|
||||
FirstReq -- Yes --> ForwardEnforcement{"Endpoint enforcement"}
|
||||
ForwardEnforcement -- "None or HTTP" --> HttpReq["Parsed HTTP request"]
|
||||
ForwardEnforcement -- "Protocol processor" --> BadForward["Deny: HTTP request for native protocol endpoint"]
|
||||
|
||||
FirstReq -- No --> Prepare["Prepare readable client stream"]
|
||||
Prepare --> TlsPolicy{"TLS handling enabled?"}
|
||||
TlsPolicy -- No --> Readable["Client stream"]
|
||||
TlsPolicy -- Yes --> Peek["Peek client bytes"]
|
||||
Peek --> Tls{"TLS ClientHello?"}
|
||||
Tls -- Yes --> Terminate["Shared TLS terminator"]
|
||||
Tls -- No --> Readable
|
||||
Terminate --> Readable
|
||||
|
||||
Readable --> Enforce{"Endpoint enforcement"}
|
||||
Enforce -- "None" --> Sniff{"HTTP request detected?"}
|
||||
Sniff -- Yes --> ParseHttp["Parse HTTP request"]
|
||||
Sniff -- No --> TcpRelay["TcpRelay<br/>connect upstream and copy bytes"]
|
||||
ParseHttp --> HttpReq
|
||||
|
||||
Enforce -- "HTTP" --> MustHttp{"HTTP request detected?"}
|
||||
MustHttp -- Yes --> ParseHttp
|
||||
MustHttp -- No --> DenyHttp["Deny: expected HTTP"]
|
||||
|
||||
Enforce -- "Protocol processor" --> Processor["TcpRelay hands stream to protocol processor"]
|
||||
Processor --> ProcessorOwns["Processor owns message loop<br/>and calls connector when allowed"]
|
||||
|
||||
subgraph HttpLoop["HTTP relay request loop"]
|
||||
HttpReq --> HttpMode{"HTTP endpoint policy?"}
|
||||
HttpMode -- "L4-only HTTP" --> ReqAllowed["Request admitted by connection decision"]
|
||||
HttpMode -- "REST / GraphQL / JSON-RPC / MCP / WebSocket" --> ReqPolicy{"Request policy allowed?"}
|
||||
ReqPolicy -- No --> ReqDeny["Local HTTP deny<br/>no upstream write"]
|
||||
ReqPolicy -- Yes --> ReqAllowed
|
||||
ReqAllowed --> Middleware{"Supervisor middleware<br/>configured?"}
|
||||
Middleware -- Yes --> MwEval["Run HTTP_REQUEST / PRE_CREDENTIALS middleware"]
|
||||
Middleware -- No --> Creds["Resolve static placeholders<br/>and token grants"]
|
||||
MwEval --> MwAllowed{"Middleware allowed?"}
|
||||
MwAllowed -- No --> MwDeny["Local middleware deny<br/>no credential injection"]
|
||||
MwAllowed -- Yes --> Recheck["Re-parse transformed protocol body<br/>and re-evaluate request policy"]
|
||||
Recheck --> PostMwAllowed{"Allowed under endpoint<br/>enforcement mode?"}
|
||||
PostMwAllowed -- No --> PostMwDeny["Local policy deny<br/>no credential injection"]
|
||||
PostMwAllowed -- Yes --> Creds
|
||||
Creds --> Rewrite["Inject credentials into configured slots"]
|
||||
Rewrite --> HttpDial["Connect or reuse upstream"]
|
||||
HttpDial --> HttpResponse["Write request and relay response"]
|
||||
HttpResponse --> Upgrade{"101 WebSocket upgrade?"}
|
||||
Upgrade -- No --> NextReq{"Another HTTP request<br/>on this connection?"}
|
||||
NextReq -- Yes --> HttpReq
|
||||
NextReq -- No --> Done["HTTP relay done"]
|
||||
Upgrade -- Yes --> WsInspect{"WebSocket inspection<br/>or rewrite configured?"}
|
||||
WsInspect -- No --> RawUpgrade["Raw upgraded stream"]
|
||||
WsInspect -- Yes --> WsRelay["WebSocket relay<br/>text-frame rewrite / message policy"]
|
||||
end
|
||||
```
|
||||
|
||||
Read this as two phases. The top half chooses the relay shape from the adapter
|
||||
surface and endpoint enforcement. The `HTTP relay request loop` only receives a
|
||||
parsed HTTP request. Supervisor middleware is not another policy funnel; it is
|
||||
an optional request-path hook after HTTP policy allows the request and before
|
||||
OpenShell-managed credential injection. When middleware changes a request
|
||||
body, the relay re-parses body-dependent protocol inputs and re-evaluates
|
||||
request policy before credential injection or upstream write.
|
||||
|
||||
Relay rules:
|
||||
|
||||
- HTTP credential injection happens in both HTTP modes: L4-only HTTP and
|
||||
HTTP-inspected.
|
||||
- HTTP-inspected endpoints include `rest`, `graphql`, `json-rpc`, `mcp`, and
|
||||
`websocket`. JSON-RPC and MCP are HTTP L7 protocols, not native TCP protocol
|
||||
processors.
|
||||
- Supervisor middleware is a typed relay hook. V1 middleware runs on parsed
|
||||
HTTP requests at `HTTP_REQUEST / PRE_CREDENTIALS`, after network and request
|
||||
policy admit the request and before OpenShell injects credentials.
|
||||
- Middleware can allow, deny, replace the bounded request body, add approved
|
||||
headers, and emit audit-safe findings/metadata. External middleware must not
|
||||
receive OpenShell-managed credentials.
|
||||
- Middleware mutation cannot bypass request policy. After a body replacement,
|
||||
the relay re-parses and re-evaluates body-dependent GraphQL, JSON-RPC, and MCP
|
||||
policy inputs before credential injection or upstream write. A policy
|
||||
mismatch follows the endpoint's configured enforcement mode; a malformed or
|
||||
unclassifiable transformed protocol body fails closed even in audit mode.
|
||||
- Credential injection includes static placeholder rewrite and endpoint-bound
|
||||
dynamic token grants. Token grants run after policy allow and before upstream
|
||||
write; failures deny without forwarding the request.
|
||||
- Middleware-transformed content must not create a new path for resolving
|
||||
OpenShell credential placeholders unless the middleware hook is explicitly
|
||||
trusted as credential-capable. The safe default is to fail closed on newly
|
||||
introduced reserved placeholders before credential injection.
|
||||
- Static credential rewrite covers request target, query, headers, opt-in REST
|
||||
request bodies, and opt-in client-to-server WebSocket text frames.
|
||||
- HTTP L7 policy is evaluated before upstream write for each request. JSON-RPC
|
||||
and MCP evaluation parse bounded JSON-RPC-over-HTTP bodies; MCP adds
|
||||
tool-aware selectors for `tools/call`.
|
||||
- WebSocket upgrade policy is evaluated as HTTP first. After an allowed `101`
|
||||
upgrade, the WebSocket relay owns frame parsing when text-frame credential
|
||||
rewrite, WebSocket transport policy, GraphQL-over-WebSocket policy, or safe
|
||||
compression handling is configured. Other upgraded streams remain raw.
|
||||
- Forward HTTP must stay in the shared HTTP relay loop or in an equivalent
|
||||
guarded single-request relay. It must not evaluate one request and then
|
||||
switch to raw bidirectional copy.
|
||||
- `protocol: tcp` or an omitted protocol means L4 authorization plus byte copy,
|
||||
except that HTTP-looking streams may still use HTTP credential injection.
|
||||
- Future native protocol processors, such as Redis, Postgres, or MySQL, own the
|
||||
full message loop and can parse multiple commands or queries on one TCP
|
||||
session. A processor may be in-tree, middleware-backed, or a combination
|
||||
where in-tree framing exposes typed middleware hooks.
|
||||
|
||||
### Adapter Responsibilities
|
||||
|
||||
CONNECT remains the generic explicit proxy mode for HTTPS and arbitrary TCP.
|
||||
The CONNECT adapter parses `CONNECT host:port` into an `EgressIntent`, asks the
|
||||
shared authorization boundary for an `EgressDecision`, returns the tunnel-ready
|
||||
response only after the connection is allowed, and then hands the tunnel to the
|
||||
relay. The upstream connection is opened by the HTTP relay or protocol
|
||||
processor when payload policy allows it. The compatibility milestone preserves
|
||||
the current raw-relay dial point until the processor boundary exists.
|
||||
|
||||
Forward HTTP is compatibility for clients that send absolute-form HTTP
|
||||
requests. The adapter parses the first request, rewrites proxy framing only at
|
||||
the relay boundary, rejects `https://` absolute-form requests, rejects
|
||||
unsupported h2c upgrades on inspected routes, and either stays in a shared HTTP
|
||||
request loop or forces `Connection: close` for a guarded single request.
|
||||
|
||||
Transparent TCP is for native clients that do not know they are using a proxy.
|
||||
It depends on policy DNS and nftables capture. For a policy-eligible native TCP
|
||||
name, policy DNS returns a supervisor-owned synthetic IP and creates an active
|
||||
mapping from that IP to the normalized name, matched endpoint, allowed ports,
|
||||
and validated real addresses. Userland later calls
|
||||
`connect(synthetic_ip:port)`, nftables redirects the traffic to a userland
|
||||
listener, and the TCP adapter recovers the synthetic destination and exact
|
||||
mapping before building an intent.
|
||||
|
||||
Policy DNS replaces static `/etc/hosts` snapshots for native TCP names. It is
|
||||
query-driven. It first checks whether the normalized name matches an endpoint
|
||||
whose transport and protocol contract enables native TCP through policy DNS. A
|
||||
name without such an endpoint receives a local policy-denial DNS response,
|
||||
normally `REFUSED`, and is never sent to upstream DNS. An eligible name is
|
||||
resolved through trusted DNS, and every returned address is filtered through
|
||||
destination and SSRF controls before the adapter atomically publishes the
|
||||
mapping and capture state and returns the synthetic IP to userland.
|
||||
|
||||
The later connect still runs through normal authorization. The connector may
|
||||
dial only the validated real addresses pinned in the unexpired mapping; it must
|
||||
not perform an unrelated fresh resolution or treat a direct connection to one
|
||||
of those real IPs as correlated. Process identity remains independent
|
||||
authorization evidence evaluated at connect time, not the mechanism that joins
|
||||
the DNS request to the TCP connection.
|
||||
|
||||
Local service adapters stay outside the normal external egress relay:
|
||||
`inference.local` routes chat, completion, model discovery, embeddings, and
|
||||
provider-specific inference traffic through the router with local limits;
|
||||
`policy.local` exposes current policy, denial summaries, proposal submission,
|
||||
and proposal wait routes; metadata loopback serves provider metadata
|
||||
credentials to SDKs that bypass HTTP proxy variables.
|
||||
|
||||
### Network Enforcement Substrate
|
||||
|
||||
Current main uses nftables for sandbox bypass enforcement. It accepts
|
||||
proxy-bound traffic, loopback, and established flows, then rejects and
|
||||
optionally logs other TCP/UDP traffic for the bypass monitor. That is current
|
||||
enforcement, not native TCP capture.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Packet["Userland packet"] --> ProxyDest{"Proxy destination?"}
|
||||
ProxyDest -- Yes --> AcceptProxy["nftables accept"]
|
||||
ProxyDest -- No --> Capture{"Active synthetic-IP<br/>capture match?"}
|
||||
Capture -- Yes --> Redirect["nftables redirect/TPROXY to transparent adapter"]
|
||||
Capture -- No --> Reject["nftables log + reject bypass"]
|
||||
Reject --> Monitor["Bypass monitor emits OCSF"]
|
||||
```
|
||||
|
||||
Transparent TCP extends this nftables model with explicit capture rules that
|
||||
run before the reject path and are scoped to unexpired synthetic-IP mappings.
|
||||
The sandbox resolver points to policy DNS, while direct external DNS traffic
|
||||
continues to the reject path. DNS-over-HTTPS is ordinary HTTPS egress and
|
||||
requires its own allowed endpoint. Transparent capture does not add a parallel
|
||||
iptables path. The compatibility milestone leaves the current table unchanged;
|
||||
capture arrives in a later feature phase.
|
||||
|
||||
### Deployment Modes
|
||||
|
||||
| Mode | Shape | Status |
|
||||
|------|-------|--------|
|
||||
| Embedded supervisor | `openshell-sandbox` orchestrates `openshell-supervisor-network` and `openshell-supervisor-process` | Current |
|
||||
| Network-only supervisor | Networking, policy, proxy, local services, and background tasks run without a payload process leaf | Current runtime mode |
|
||||
| Standalone proxy binary | Supervisor launches networking as a separate process with explicit APIs | Future packaging/API work |
|
||||
| Sidecar proxy | Proxy runs outside the payload container but inside the sandbox boundary | Future isolation mode |
|
||||
|
||||
A pluggable proxy must expose the right userland surfaces, implement the
|
||||
gateway APIs it needs, and prove equivalent policy enforcement through tests.
|
||||
If supervisor middleware is configured, the proxy runtime must also receive the
|
||||
effective middleware service registry, validate and refresh bindings, enforce
|
||||
`fail_open` and `fail_closed`, buffer within configured caps, invoke middleware
|
||||
on the request path, and emit middleware OCSF events.
|
||||
|
||||
Process identity is mode-dependent. Embedded supervisor mode normally requires
|
||||
successful workload process, binary, and ancestor resolution; a lookup failure
|
||||
continues to deny. A trusted runtime can explicitly select the existing
|
||||
endpoint-only mode, in which identity is recorded as intentionally unavailable
|
||||
and policy evaluation keeps its current endpoint-only semantics. The refactor
|
||||
must not represent either case as a fabricated empty identity or accidentally
|
||||
convert a lookup failure into endpoint-only evaluation.
|
||||
|
||||
Future standalone and sidecar modes must advertise identity capability. A mode
|
||||
without local identity needs an explicit unavailable-identity contract and
|
||||
policy validation for binary/path predicates; it does not automatically inherit
|
||||
endpoint-only semantics. The nftables rules that force, capture, or reject
|
||||
userland traffic remain owned by the sandbox network boundary even if the proxy
|
||||
process later moves into a standalone binary or sidecar.
|
||||
|
||||
### Migration And Operational Contract
|
||||
|
||||
Mechanical adapter, destination, and relay extractions ship as isolated,
|
||||
revertible changes without a permanent feature flag. The deterministic
|
||||
authorization result is different: it first runs beside the legacy queries in
|
||||
shadow mode, reports audit-safe mismatches through internal telemetry, and
|
||||
retains the legacy evaluator through the cutover observation window.
|
||||
|
||||
Policy DNS, transparent TCP, native processors, and alternate deployment modes
|
||||
land only after the shared authorization and relay contracts are authoritative.
|
||||
Each is a separate, feature-bearing pull request or series with its own
|
||||
capability gating, migration, telemetry, and rollback plan; they are not bundled
|
||||
into the compatibility-only refactor.
|
||||
|
||||
Adapter response bytes and OCSF event class, action, disposition, severity,
|
||||
status, destination, actor, firewall rule, message, and status detail are
|
||||
compatibility surfaces. Moving code does not justify changing them. Performance
|
||||
is also measured at each phase; fewer OPA evaluations are a target to verify,
|
||||
not an unmeasured claim.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
The detailed migration plan lives in [implementation-plan.md](implementation-plan.md).
|
||||
The intended order is:
|
||||
|
||||
1. Lock down current responses, OCSF events, policy outcomes, credential
|
||||
behavior, upstream-dial timing, and local-service behavior with regression
|
||||
coverage.
|
||||
2. Introduce compatibility `EgressIntent` and `EgressDecision` envelopes while
|
||||
preserving current lookup precedence and failure defaults.
|
||||
3. Centralize destination validation behind an unopened connector.
|
||||
4. Materialize one generation-consistent authorization decision, compare it
|
||||
against the legacy queries, then cut over deterministic endpoint selection
|
||||
and fail-closed metadata handling as an independently reviewable step.
|
||||
5. Consolidate HTTP request-loop, credential, WebSocket, and middleware relay
|
||||
behavior in separately shippable subphases.
|
||||
6. Consolidate TLS handling and existing raw TCP relay selection.
|
||||
7. Preserve current local-service boundaries and remove compatibility plumbing.
|
||||
8. Add the native protocol-processor dispatch contract, then add protocol
|
||||
implementations as independently reviewed features.
|
||||
9. Add policy DNS state and transparent TCP capture with mandatory
|
||||
DNS-answer-to-connect correlation and separate mapping generations.
|
||||
10. Define capability-checked standalone or sidecar runtime contracts and
|
||||
complete cleanup after each boundary is in use.
|
||||
|
||||
Steps 1 through 7 are the compatibility foundation and may themselves span
|
||||
several pull requests. Steps 8 through 10 are later feature milestones; their
|
||||
presence in this RFC defines the direction without adding them to the initial
|
||||
refactor branch.
|
||||
|
||||
## Risks
|
||||
|
||||
- Tightening L7 and TLS metadata failures from fail-open to deny may expose
|
||||
latent policy or Rego errors. `allowed_ips` and SSRF validation already fail
|
||||
more conservatively; tests must cover each query independently.
|
||||
- Deterministic endpoint selection may change ambiguous overlapping policies.
|
||||
The new decision must shadow the legacy queries and report mismatches before
|
||||
any semantic cutover.
|
||||
- Token grants add a runtime dependency on SPIFFE Workload API and token
|
||||
endpoints. Failures should remain fail-closed and sanitized.
|
||||
- Transparent TCP capture adds network-namespace interception and mutable DNS
|
||||
mapping state. Synthetic address allocation must coexist with runtime
|
||||
networks, avoid premature reuse, prevent unrelated bare-IP connections from
|
||||
inheriting DNS authorization, and fail closed across policy or mapping
|
||||
generation changes.
|
||||
- Sidecar or standalone modes may intentionally lack process identity.
|
||||
Binary/path-scoped policy needs an advertised identity capability and policy
|
||||
validation; missing identity cannot silently broaden an allow.
|
||||
- Metadata loopback and `policy.local` expand sandbox-local control surfaces
|
||||
and need strict route validation, body limits, redaction, and authentication
|
||||
boundaries.
|
||||
- Provider-composed policy rules use a reserved namespace. Decisions and logs
|
||||
must distinguish provider-derived policy from user-authored policy without
|
||||
exposing provider rules as editable sandbox proposals.
|
||||
- Supervisor middleware adds a synchronous request-path dependency. Body caps,
|
||||
timeout behavior, registry reloads, and `fail_open` choices must be visible
|
||||
in telemetry so operators can diagnose whether content inspection ran.
|
||||
- Moving OCSF emission sites can accidentally change event class, action,
|
||||
disposition, severity, message, or actor/destination fields. Adapter response
|
||||
shapes and OCSF schemas are compatibility requirements, not cleanup targets.
|
||||
- New decision/context objects add per-connection allocations on a hot path.
|
||||
Performance claims require before/after measurements of OPA evaluation count,
|
||||
allocation volume, and connection/request latency.
|
||||
- Structural phases back out by reverting their isolated commits. The
|
||||
deterministic-decision cutover must retain the legacy evaluator long enough
|
||||
for shadow comparison and immediate rollback; a permanent feature flag is not
|
||||
required for the purely mechanical phases.
|
||||
|
||||
## Alternatives
|
||||
|
||||
### Keep patching each entry path
|
||||
|
||||
This has the lowest short-term cost but keeps security behavior duplicated
|
||||
across CONNECT, forward HTTP, and local services. It also makes future TCP
|
||||
application protocol support harder because each parser must be wired through
|
||||
multiple entry mechanisms.
|
||||
|
||||
### Replace CONNECT with forward proxy
|
||||
|
||||
Forward proxy only covers plaintext absolute-form HTTP requests. It is not a
|
||||
replacement for HTTPS tunnels, WebSocket tunnels, or arbitrary TCP clients.
|
||||
CONNECT should remain the generic explicit proxy mode.
|
||||
|
||||
### Build only transparent TCP
|
||||
|
||||
Transparent TCP helps native clients but does not replace explicit proxy
|
||||
support used by common HTTP tooling. It also requires the shared authorization,
|
||||
destination, and relay boundaries plus policy DNS and nftables capture before
|
||||
it can safely preserve endpoint identity. For that reason it is a later phase
|
||||
of this RFC, not the first implementation change.
|
||||
|
||||
### Return real addresses from policy DNS
|
||||
|
||||
Returning validated real addresses avoids a synthetic address pool, but the
|
||||
later TCP connection carries only an IP and port. Two policy names can share
|
||||
the same real address and port, and a direct bare-IP connection is
|
||||
indistinguishable from one caused by the earlier lookup. Process identity does
|
||||
not solve shared resolvers, caches, cross-process handoff, or two names resolved
|
||||
by the same process. The proposal therefore uses a synthetic address as the
|
||||
correlation handle and keeps process identity as separate authorization
|
||||
evidence.
|
||||
|
||||
## Prior art
|
||||
|
||||
The current `openshell-supervisor-network` split is the immediate prior step:
|
||||
it already separates proxy, OPA, L7, inference routing, policy-local routes,
|
||||
TLS, and token grants from process supervision.
|
||||
|
||||
The current `openshell-supervisor-process` netns and bypass monitor are the
|
||||
packet-enforcement substrate. Transparent TCP extends that nftables model in a
|
||||
later phase rather than creating a second firewall path.
|
||||
|
||||
The existing L7 relay is the behavioral prior art for this RFC. It already
|
||||
proves per-request HTTP evaluation, GraphQL parsing, JSON-RPC/MCP body
|
||||
inspection, WebSocket frame handling, request-body rewrite, and token-grant
|
||||
injection can live behind relay boundaries.
|
||||
|
||||
RFC 0009 supervisor middleware is the extension prior art. It defines
|
||||
`HTTP_REQUEST / PRE_CREDENTIALS` as a supervisor-owned hook that can inspect,
|
||||
deny, or transform admitted HTTP requests before credentials are injected. RFC
|
||||
0005 should place that hook inside the shared relay rather than making each
|
||||
adapter wire middleware separately.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should overlapping endpoint metadata be rejected at policy load time, or
|
||||
should one documented policy/endpoint precedence key select the complete
|
||||
decision? The initial compatibility refactor does not choose between them.
|
||||
2. What mismatch-free observation window is sufficient before the
|
||||
deterministic decision replaces the legacy endpoint queries?
|
||||
3. Should metadata loopback be modeled as an adapter inside
|
||||
`openshell-supervisor-network`, or remain orchestrated by `openshell-sandbox`
|
||||
with shared credential/provider helpers?
|
||||
4. What TTL cap should policy DNS use, and should policy reload immediately
|
||||
invalidate all active mappings or permit a bounded drain period that cannot
|
||||
authorize new connections?
|
||||
5. Which non-routable synthetic IPv4 and IPv6 ranges can each runtime reserve,
|
||||
and what quarantine period prevents address reuse while stale DNS answers
|
||||
may remain cached?
|
||||
6. Which original-destination mechanism and nftables redirect mode should each
|
||||
supported runtime use while keeping capture rules ahead of bypass rejection?
|
||||
7. Which identity capabilities must standalone and sidecar runtimes advertise
|
||||
before the gateway accepts binary/path-scoped policy for them?
|
||||
@@ -0,0 +1,286 @@
|
||||
# Current Shape Appendix
|
||||
|
||||
This appendix records the current proxy shape and the review findings that
|
||||
motivate the adapter model. The main RFC intentionally keeps these details out
|
||||
of the direction document.
|
||||
|
||||
## Current Runtime Split
|
||||
|
||||
The proxy is no longer only a large module inside `openshell-sandbox`.
|
||||
Current main has three relevant runtime owners:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Sandbox["openshell-sandbox<br/>orchestrator"]
|
||||
Network["openshell-supervisor-network<br/>proxy, OPA, L7, TLS, inference,<br/>policy.local, token grants"]
|
||||
Process["openshell-supervisor-process<br/>process leaf, SSH, netns,<br/>nftables, bypass monitor"]
|
||||
Denials["Denial/activity aggregators"]
|
||||
Gateway["Gateway policy/provider APIs"]
|
||||
|
||||
Sandbox --> Network
|
||||
Sandbox --> Process
|
||||
Network --> Denials
|
||||
Process --> Denials
|
||||
Sandbox --> Gateway
|
||||
Network --> Gateway
|
||||
```
|
||||
|
||||
`openshell-sandbox` creates the shared network namespace, owns denial/activity
|
||||
channels, starts the policy poll loop, starts networking, starts the metadata
|
||||
loopback server when needed, and then optionally starts the process leaf. If
|
||||
`process_enabled` is false, the supervisor can run in network-only mode and
|
||||
keep networking/background tasks alive until shutdown.
|
||||
|
||||
`openshell-supervisor-network` owns the explicit proxy listener, OPA engine
|
||||
integration, L7 enforcement, TLS termination, inference routing, policy-local
|
||||
routes, identity cache, provider credential injection, and token grants.
|
||||
|
||||
`openshell-supervisor-process` owns process execution, SSH, network namespace
|
||||
helpers, nftables bypass rules, and the bypass monitor that turns nftables LOG
|
||||
entries into OCSF events.
|
||||
|
||||
In embedded supervisor mode, the network leaf normally uses process metadata
|
||||
resolved by the process/orchestrator side for binary-scoped policy and OCSF
|
||||
context. Current runtime configuration can instead select endpoint-only policy
|
||||
evaluation. The adapter model must preserve that intentional mode separately
|
||||
from an identity lookup failure; the latter continues to deny when binary
|
||||
identity is required.
|
||||
|
||||
## Current Userland-Facing Surfaces
|
||||
|
||||
The networking surface currently includes:
|
||||
|
||||
- CONNECT proxy traffic for HTTPS and generic TCP tunnels.
|
||||
- Forward HTTP proxy traffic for absolute-form HTTP requests.
|
||||
- `inference.local` for local inference routing.
|
||||
- `policy.local` for current policy, denial summaries, proposal submission,
|
||||
and proposal wait routes.
|
||||
- GCE metadata loopback for SDKs that bypass HTTP proxy variables.
|
||||
- nftables bypass enforcement for direct TCP/UDP egress that does not enter
|
||||
the proxy.
|
||||
- OPA/Rego policy and endpoint metadata lookups.
|
||||
- DNS resolution and endpoint validation for CONNECT and forward HTTP egress.
|
||||
- Static provider credential injection and redaction.
|
||||
- Endpoint-bound dynamic token grant injection.
|
||||
- Opt-in REST request-body credential rewrite.
|
||||
- L7 REST, GraphQL, JSON-RPC, MCP, WebSocket, and
|
||||
GraphQL-over-WebSocket enforcement.
|
||||
|
||||
The issue is not that these features exist. The issue is that entry mechanisms,
|
||||
policy evaluation, endpoint metadata lookup, credential injection, and byte
|
||||
relay decisions are still interleaved.
|
||||
|
||||
## Current CONNECT Shape
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Client["Client CONNECT host:port"] --> Parse["Parse CONNECT target"]
|
||||
Parse --> L4["Evaluate network policy"]
|
||||
L4 --> Allowed{"Allowed?"}
|
||||
Allowed -- No --> Deny["CONNECT denial"]
|
||||
Allowed -- Yes --> Meta["Query endpoint metadata"]
|
||||
Meta --> Config{"L7, TLS, or credential config?"}
|
||||
Config -- No --> Tunnel["Return tunnel-ready response"]
|
||||
Config -- Yes --> Tunnel
|
||||
Tunnel --> Inspect["Inspect tunneled bytes when possible"]
|
||||
Inspect --> Relay["HTTP/WebSocket/TCP relay selection"]
|
||||
Relay --> Inject["Middleware, static credentials, and token grants if configured"]
|
||||
Inject --> Upstream["Open upstream when relay policy allows"]
|
||||
```
|
||||
|
||||
CONNECT is still the strongest entry shape because the tunnel relay can keep
|
||||
parsing HTTP requests on long-lived connections and enforce request policy per
|
||||
request.
|
||||
|
||||
## Current Forward HTTP Shape
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Client["Absolute-form HTTP request"] --> Parse["Parse first request"]
|
||||
Parse --> L4["Evaluate network policy"]
|
||||
L4 --> Allowed{"Allowed?"}
|
||||
Allowed -- No --> Deny["HTTP denial"]
|
||||
Allowed -- Yes --> L7{"Matching L7 endpoint?"}
|
||||
L7 -- Yes --> Eval["Evaluate REST/GraphQL/JSON-RPC/MCP/WebSocket policy"]
|
||||
Eval --> Guard["Reject unsupported h2c upgrade when inspected"]
|
||||
Guard --> Rewrite["Rewrite to origin-form + configured credentials"]
|
||||
L7 -- No --> Rewrite
|
||||
Rewrite --> Token["Apply token grant if endpoint-bound"]
|
||||
Token --> Close["Force Connection: close except WebSocket upgrade"]
|
||||
Close --> Upstream["Open upstream"]
|
||||
Upstream --> Relay["Guarded HTTP relay / upgrade relay"]
|
||||
```
|
||||
|
||||
Latest main no longer has the old raw-copy-after-first-request shape for
|
||||
ordinary forward HTTP. It rewrites ordinary requests with `Connection: close`,
|
||||
uses guarded HTTP relay helpers for body handling, rejects inspected h2c
|
||||
upgrades, injects token grants, and sends allowed WebSocket upgrades through
|
||||
the upgrade relay. That is a narrower surface than the historical bidirectional
|
||||
copy, but it is still orchestrated separately from the CONNECT relay path.
|
||||
|
||||
## Current Local Service Shape
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Request["Request to local name"] --> Match{"Known local route?"}
|
||||
Match -- "inference.local" --> Inference["Inference route adapter"]
|
||||
Match -- "policy.local" --> Policy["Policy local adapter"]
|
||||
Match -- "metadata loopback" --> Metadata["Metadata credential server"]
|
||||
Match -- No --> External["Normal egress path"]
|
||||
Inference --> InferenceResp["Local inference response"]
|
||||
Policy --> PolicyResp["Local policy response"]
|
||||
Metadata --> MetadataResp["Metadata response"]
|
||||
```
|
||||
|
||||
`inference.local` now covers buffered and streaming inference shapes including
|
||||
chat/completion routes, model discovery, embeddings, and provider-specific
|
||||
routes. `policy.local` supports the agentic approval loop: agents can submit
|
||||
narrow proposals and wait on approval/reload before retrying. Metadata
|
||||
loopback exists for provider credentials consumed by SDKs that do not honor
|
||||
HTTP proxy variables.
|
||||
|
||||
These are userland-facing network surfaces. They should stay distinct from
|
||||
external egress while still fitting the adapter model.
|
||||
|
||||
## Adjacent In-Flight Supervisor Middleware Shape
|
||||
|
||||
PRs #1738 and #2027 propose supervisor middleware as an HTTP request hook in
|
||||
the proxy relay. That work is adjacent to this RFC rather than a separate entry
|
||||
adapter.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Req["Parsed admitted HTTP request"] --> Policy["Network and request policy already allowed"]
|
||||
Policy --> Hook["HTTP_REQUEST / PRE_CREDENTIALS middleware"]
|
||||
Hook --> Outcome{"Middleware outcome"}
|
||||
Outcome -- "deny" --> Deny["Local deny, no credential injection"]
|
||||
Outcome -- "allow / mutate" --> Recheck["Re-parse transformed body and re-evaluate policy"]
|
||||
Recheck --> Allowed{"Allowed under endpoint enforcement mode?"}
|
||||
Allowed -- "no" --> PolicyDeny["Local policy deny, no credential injection"]
|
||||
Allowed -- "yes" --> Creds["Credential injection"]
|
||||
Creds --> Upstream["Upstream write"]
|
||||
```
|
||||
|
||||
The proposed middleware chain is selected by admitted destination host, runs in
|
||||
deterministic order, buffers bounded request bodies, applies `fail_open` or
|
||||
`fail_closed`, emits audit-safe findings, and runs before OpenShell-managed
|
||||
credentials are injected. Middleware-transformed GraphQL, JSON-RPC, and MCP
|
||||
bodies are re-parsed and re-evaluated before credential injection or upstream
|
||||
write, so a mutation cannot bypass request policy. Policy mismatches retain the
|
||||
endpoint's audit or enforce behavior, while malformed transformed protocol
|
||||
bodies fail closed in either mode. Middleware can inspect WebSocket upgrade
|
||||
requests because they are HTTP requests, but it does not inspect post-upgrade
|
||||
WebSocket frames in v1.
|
||||
|
||||
RFC 0005 should account for this by treating middleware as part of the shared
|
||||
request processing plan. CONNECT and forward HTTP should not each learn how to
|
||||
select and invoke middleware independently.
|
||||
|
||||
## Current Network Namespace Enforcement
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start["Process in sandbox network namespace"] --> Dest{"Destination"}
|
||||
Dest -- "Proxy host_ip:port" --> Proxy["Accept to sandbox proxy"]
|
||||
Dest -- "Loopback" --> Loopback["Accept loopback"]
|
||||
Dest -- "Established/related" --> Established["Accept response packet"]
|
||||
Dest -- "Other TCP/UDP" --> Reject["nftables log + reject"]
|
||||
Reject --> Monitor["Bypass monitor reads dmesg"]
|
||||
Monitor --> OCSF["OCSF network + detection events"]
|
||||
```
|
||||
|
||||
The process leaf installs an `inet` nftables filter table for bypass
|
||||
enforcement. The table accepts proxy-bound traffic, loopback, and established
|
||||
flows, then rejects and optionally logs other TCP/UDP traffic. It does not
|
||||
currently redirect native TCP connections into the proxy.
|
||||
|
||||
## Findings To Preserve
|
||||
|
||||
### Invariant: forward proxy must not relay unevaluated follow-on HTTP bytes
|
||||
|
||||
The historical forward path evaluated at most the first absolute-form request,
|
||||
rewrote it, then switched to bidirectional copy. Bytes already buffered after
|
||||
the first header block, or later pipelined requests on the same client/upstream
|
||||
connection, could reach upstream without the CONNECT L7 relay's per-request
|
||||
parser/evaluator.
|
||||
|
||||
Latest main mitigates this by forcing ordinary forward HTTP to one request per
|
||||
connection and by using guarded relay helpers. The adapter model should
|
||||
preserve the invariant either by keeping forward HTTP single-request/close or
|
||||
by passing the first parsed request into a shared HTTP relay loop.
|
||||
|
||||
### Endpoint config is not tied to deterministic matched policy
|
||||
|
||||
The policy name used for L4 authorization and logging is the lexicographically
|
||||
smallest matching policy. L7 candidates are collected independently and later
|
||||
selected by request-path specificity. TLS and `allowed_ips` use the first
|
||||
extended endpoint config returned by a separate query. Exact-declared-host is
|
||||
another independent existential query. With overlapping host, port, and binary
|
||||
rules, those results can describe different endpoints and policies on the same
|
||||
connection.
|
||||
|
||||
The adapter model requires authorization to return one decision with one
|
||||
deterministic matched endpoint.
|
||||
|
||||
### Policy materialization can span generations
|
||||
|
||||
The L4 decision, L7 route, TLS mode, `allowed_ips`, and exact-declared-host
|
||||
signal are materialized through separate engine calls. The L7 route records a
|
||||
generation for its tunnel evaluator, but the sibling metadata queries are not
|
||||
all asserted against the L4 decision generation. A reload during setup can
|
||||
therefore assemble one logical connection decision from different policy
|
||||
generations.
|
||||
|
||||
The target decision carries one top-level policy generation. Every
|
||||
policy-derived field must be evaluated from that generation, and relay startup
|
||||
must reject a stale decision before an upstream request is written.
|
||||
|
||||
### Endpoint metadata query failures should not erase enforcement
|
||||
|
||||
Failure behavior is not uniform today. L7 configuration failure becomes no L7
|
||||
configuration, and TLS configuration failure becomes automatic TLS handling;
|
||||
either can erase intended enforcement. `allowed_ips` and the downstream SSRF
|
||||
validation path are already more conservative. The migration must test each
|
||||
query independently instead of describing all metadata failures as equivalent.
|
||||
|
||||
The adapter model treats endpoint metadata as part of the authorization result.
|
||||
Failure to materialize required metadata should deny rather than erase extended
|
||||
configuration.
|
||||
|
||||
### Destination validation must be shared
|
||||
|
||||
Private address checks, `allowed_ips`, exact declared private endpoint trust,
|
||||
trusted gateway aliases, SSRF checks, and control-plane port blocks have grown
|
||||
over time. They should be centralized so CONNECT and forward HTTP use the same
|
||||
resolved-destination rules. Existing local services remain outside normal
|
||||
external destination validation.
|
||||
|
||||
## Existing Feature Inventory
|
||||
|
||||
The refactor should preserve:
|
||||
|
||||
- CONNECT explicit proxy support.
|
||||
- Forward HTTP explicit proxy support.
|
||||
- Network-only supervisor mode.
|
||||
- nftables bypass reject/log enforcement.
|
||||
- Provider credential injection and redaction.
|
||||
- Dynamic token grant injection through SPIFFE-backed provider credentials.
|
||||
- Supervisor middleware `HTTP_REQUEST / PRE_CREDENTIALS` when it lands.
|
||||
- REST request-body credential rewrite.
|
||||
- WebSocket text-frame credential rewrite.
|
||||
- REST endpoint method/path policy.
|
||||
- GraphQL-over-HTTP policy.
|
||||
- JSON-RPC-over-HTTP method policy.
|
||||
- MCP Streamable HTTP method and tool policy.
|
||||
- WebSocket transport and GraphQL-over-WebSocket policy.
|
||||
- h2c rejection on inspected HTTP routes.
|
||||
- Inference routing through `inference.local`, including embeddings.
|
||||
- Agent-facing policy advisor routes through `policy.local`.
|
||||
- GCE metadata loopback for supported provider credentials.
|
||||
- Timeout and resource tracking for client, upstream, and local service work.
|
||||
- Structured OCSF logging for network and HTTP policy outcomes.
|
||||
- SSRF and internal address protections.
|
||||
- Exact declared private endpoint handling.
|
||||
- Control-plane port protection.
|
||||
- `allowed_ips` endpoint restrictions.
|
||||
- TLS auto-detection and termination for inspectable client connections.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Implementation Plan
|
||||
|
||||
This plan is intentionally separate from the main RFC so the proposal can stay
|
||||
direction-focused. The RFC is an incremental roadmap, not one pull request.
|
||||
Phases 0 through 7 form the compatibility foundation: they restructure current
|
||||
CONNECT, forward HTTP, raw TCP, and local-service behavior without adding a new
|
||||
user-facing transport. Phases 8 and later add the forward-looking capabilities
|
||||
after the shared contracts are authoritative.
|
||||
|
||||
## Phase 0 - Compatibility Baseline
|
||||
|
||||
- Cover CONNECT and forward HTTP allow/deny responses, including exact status,
|
||||
headers, and adapter-specific error bodies.
|
||||
- Cover forward HTTP pipelining, keep-alive follow-on requests, the current
|
||||
`Connection: close` mitigation, `https://` absolute-form rejection, and h2c
|
||||
rejection on inspected endpoints.
|
||||
- Cover the current overlapping-policy outcomes separately for matched policy,
|
||||
L7 route selection, TLS, `allowed_ips`, and exact-declared-host.
|
||||
- Inject failures into L7, TLS, `allowed_ips`, and exact-declared-host queries.
|
||||
Record the current fail-open or fail-closed result for each query rather than
|
||||
treating all endpoint metadata errors as equivalent.
|
||||
- Cover control-plane ports, cloud metadata, always-blocked addresses, exact
|
||||
declared private endpoints, IP-literal synthesis, trusted gateway aliases,
|
||||
and explicit `allowed_ips` through CONNECT and forward HTTP.
|
||||
- Cover identity-required success/failure, unsupported-platform behavior where
|
||||
possible, and intentional endpoint-only evaluation. Prove an empty
|
||||
`exec.path` cannot satisfy binary-scoped policy while identity is required.
|
||||
- Cover static credential injection, token grants, REST body rewrite,
|
||||
WebSocket text-frame rewrite and policy, GraphQL, JSON-RPC, and MCP behavior.
|
||||
- Cover `inference.local`, `policy.local`, metadata loopback, and unchanged
|
||||
nftables bypass reject/log behavior.
|
||||
- Capture stable OCSF event class, activity/action/disposition, severity,
|
||||
status, destination, actor, firewall rule, message, and status detail for
|
||||
representative allow and deny paths.
|
||||
- Record a performance baseline for OPA evaluations, per-connection
|
||||
allocations, and CONNECT/forward request latency.
|
||||
|
||||
## Phase 1 - Adapters And Compatibility Decision Envelope
|
||||
|
||||
- Introduce CONNECT and forward HTTP `EgressIntent` construction inside
|
||||
`openshell-supervisor-network`.
|
||||
- Introduce a transitional `EgressDecision` carrying L4 outcome, policy
|
||||
generation, process evidence, and endpoint fields while preserving the
|
||||
current query timing, precedence, and failure defaults.
|
||||
- Keep `LookupFailed`/unsupported identity as a denial when identity is
|
||||
required. Keep explicitly configured endpoint-only mode behavior unchanged.
|
||||
- Keep adapter-specific responses and OCSF emission at the protocol boundary.
|
||||
- Do not claim the transitional decision is one atomic OPA result; document its
|
||||
compatibility hydration until Phase 3 cuts over.
|
||||
|
||||
This phase is a mechanical extraction. It must be independently shippable and
|
||||
revertible without changing user-visible policy or relay behavior.
|
||||
|
||||
## Phase 2 - Shared Destination Validation
|
||||
|
||||
- Move DNS resolution, explicit `allowed_ips`, exact declared endpoints,
|
||||
implicit IP-literal handling, trusted gateway aliases, SSRF checks,
|
||||
cloud-metadata blocks, and control-plane-port blocks into one validator.
|
||||
- Represent the selected validation mode explicitly instead of passing an
|
||||
ambiguous collection of booleans.
|
||||
- Return an unopened `UpstreamConnector` so adapters and relays preserve the
|
||||
current point at which upstream TCP is created.
|
||||
- Prove CONNECT and forward HTTP retain their existing denial responses, OCSF
|
||||
fields, and dial timing while using the shared validator.
|
||||
|
||||
## Phase 3 - Generation-Consistent Authorization Cutover
|
||||
|
||||
- Define either rejection of ambiguous overlapping endpoint metadata or one
|
||||
documented policy/endpoint precedence key before changing enforcement.
|
||||
- Add one OPA result that materializes matched policy/source, matched endpoint,
|
||||
destination constraints, TLS, HTTP enforcement, credential plan, and
|
||||
middleware selection from one policy generation.
|
||||
- Attach a generation-pinned `TunnelPolicyEngine` to relay context for
|
||||
per-request REST, GraphQL, JSON-RPC, MCP, and WebSocket evaluation. Relays do
|
||||
not rematerialize connection-level endpoint policy.
|
||||
- Run the new query in shadow mode beside legacy queries. Emit internal,
|
||||
audit-safe mismatch telemetry without changing existing OCSF network/HTTP
|
||||
events or enforcement.
|
||||
- Add reload-race tests proving every materialized field matches the top-level
|
||||
generation and stale decisions stop before upstream request write.
|
||||
- Make deterministic selection and fail-closed L7/TLS metadata errors a
|
||||
dedicated cutover only after mismatch cases are understood. Retain the
|
||||
legacy evaluator temporarily for immediate rollback.
|
||||
|
||||
This is the only phase that intentionally tightens ambiguous or error behavior;
|
||||
it must not be hidden inside the structural refactor commits.
|
||||
|
||||
## Phase 4 - Forward HTTP Adapter
|
||||
|
||||
- Keep absolute-form parsing and adapter-specific errors at the forward HTTP
|
||||
boundary.
|
||||
- Pass the buffered first request into a shared HTTP relay, or retain the
|
||||
guarded single-request/`Connection: close` path until Phase 5a is ready.
|
||||
- Preserve `https://` absolute-form rejection and inspected h2c rejection.
|
||||
- Preserve the invariant that no unevaluated follow-on request can reach raw
|
||||
bidirectional copy.
|
||||
|
||||
## Phase 5 - Relay Consolidation
|
||||
|
||||
### Phase 5a - HTTP request loop
|
||||
|
||||
- Centralize HTTP parsing and per-request REST, GraphQL, JSON-RPC, and MCP
|
||||
evaluation behind the generation-pinned request-policy handle.
|
||||
- Evaluate every request before upstream write and preserve the current rule
|
||||
that a denied request does not create an upstream session.
|
||||
- Preserve bounded JSON-RPC/MCP inspection and audit-safe logging that omits
|
||||
params and tool arguments.
|
||||
|
||||
### Phase 5b - Credential injection
|
||||
|
||||
- Unify static target/query/header rewrite, endpoint-bound token grants, and
|
||||
opt-in REST request-body rewrite after request allow and before upstream
|
||||
write.
|
||||
- Preserve buffering limits, supported content types, `Content-Length`
|
||||
recomputation, redaction, token caching, and fail-closed unresolved secrets.
|
||||
|
||||
### Phase 5c - WebSocket
|
||||
|
||||
- Move allowed upgrades behind the shared relay while preserving raw upgraded
|
||||
passthrough, opt-in text-frame credential rewrite, WebSocket transport policy,
|
||||
GraphQL-over-WebSocket policy, and safe compression behavior.
|
||||
|
||||
### Phase 5d - Supervisor middleware
|
||||
|
||||
- Land only after the supervisor middleware dependency is available.
|
||||
- Run `HTTP_REQUEST / PRE_CREDENTIALS` after request allow and before static or
|
||||
dynamic credential injection.
|
||||
- Re-parse middleware-transformed bodies and re-evaluate GraphQL, JSON-RPC, and
|
||||
MCP policy inputs before credential injection or upstream write. Preserve the
|
||||
endpoint's audit or enforce behavior for policy mismatches, and fail closed
|
||||
on malformed transformed protocol bodies in either mode.
|
||||
- Preserve ordering, body caps, `fail_open`/`fail_closed`, safe headers,
|
||||
findings, metadata, and rejection of middleware-introduced credential
|
||||
placeholders.
|
||||
- Test allowed requests that middleware rewrites into denied GraphQL, JSON-RPC,
|
||||
and MCP operations, including audit-mode forwarding and fail-closed malformed
|
||||
replacements.
|
||||
|
||||
Each subphase must be independently testable and shippable; Phase 5 is not a
|
||||
single flag-day cutover.
|
||||
|
||||
## Phase 6 - Shared TLS And TCP Relay Boundary
|
||||
|
||||
- Move client-side TLS detection and termination before the HTTP/raw-TCP relay
|
||||
split without changing handshake, certificate, or upstream-connect timing.
|
||||
- Keep endpoint TLS behavior on `EgressDecision` and preserve `tls: skip` as the
|
||||
explicit raw-tunnel path.
|
||||
- Use one existing raw `TcpRelay` byte-copy primitive for L4 traffic.
|
||||
- Add a protocol-processor dispatch contract without enabling a concrete new
|
||||
protocol in the compatibility milestone.
|
||||
- Let processors own their message loop and call the validated connector only
|
||||
when protocol state allows. Permit in-tree, middleware-backed, and hybrid
|
||||
processors with typed middleware operations.
|
||||
|
||||
## Phase 7 - Existing Local Services And Cleanup
|
||||
|
||||
- Keep `inference.local` as a local adapter with its existing TLS, route,
|
||||
provider-auth, streaming/buffered limit, and OCSF behavior.
|
||||
- Keep `policy.local` as a local adapter for current policy, bounded denial
|
||||
summaries, proposals, and proposal wait.
|
||||
- Decide whether metadata loopback remains orchestrated by `openshell-sandbox`
|
||||
or moves behind a local adapter boundary; preserve startup/failure behavior
|
||||
either way.
|
||||
- Keep the local-routing and destination contracts extensible for issue
|
||||
[#1633](https://github.com/NVIDIA/OpenShell/issues/1633), while leaving its
|
||||
policy surface and host-loopback authorization to separate feature work.
|
||||
- Remove compatibility endpoint queries only after Phase 3 is authoritative.
|
||||
- Remove duplicated destination/relay plumbing without centralizing
|
||||
adapter-specific response rendering.
|
||||
- Update the living architecture documentation once each implemented boundary
|
||||
reflects current code.
|
||||
|
||||
Completion of Phase 7 is the compatibility milestone: existing user-facing
|
||||
features and capabilities are preserved on the new internal structure. The
|
||||
following phases are feature-bearing work and land in separate pull requests or
|
||||
series.
|
||||
|
||||
## Phase 8 - Policy DNS And Transparent TCP
|
||||
|
||||
- Add policy DNS registration for native TCP endpoint names.
|
||||
- Reject names that do not match an eligible native TCP endpoint before making
|
||||
an upstream DNS query.
|
||||
- Replace static host-file mapping with query-driven synthetic DNS answers.
|
||||
Resolve eligible names through trusted DNS and filter every real address
|
||||
through destination controls.
|
||||
- Allocate a supervisor-owned synthetic IP and store the normalized name,
|
||||
endpoint ID, allowed ports, validated real addresses, policy generation,
|
||||
distinct DNS mapping generation, mapping ID, and expiration in active mapping
|
||||
state.
|
||||
- Require every captured connect to correlate with the unexpired mapping
|
||||
selected by its synthetic destination and requested port. Do not allow
|
||||
unrelated bare-IP traffic to inherit a policy-DNS decision.
|
||||
- Publish mapping and nftables capture updates atomically from the adapter's
|
||||
perspective before returning the synthetic DNS answer.
|
||||
- Add nftables REDIRECT/TPROXY capture rules ahead of the bypass reject path;
|
||||
do not add a parallel iptables path.
|
||||
- Coordinate capture-rule ownership with
|
||||
`openshell-supervisor-process::netns` and preserve reject/log fallback for
|
||||
unmatched traffic.
|
||||
- Recover the original destination, construct a transparent-TCP intent, and run
|
||||
normal generation-consistent authorization and destination validation.
|
||||
- Restrict the connector to the mapping's pinned validated real addresses; do
|
||||
not independently re-resolve at connect time.
|
||||
- Keep direct external DNS blocked and treat DNS-over-HTTPS as ordinary
|
||||
policy-controlled HTTPS egress.
|
||||
- Define synthetic address pools and reuse quarantine, TTL caps, policy-reload
|
||||
invalidation, stale-mapping behavior, and rollback before enabling capture by
|
||||
default.
|
||||
|
||||
## Phase 9 - Native Protocol Processors
|
||||
|
||||
- Add concrete Redis, Postgres, MySQL, or other processors one protocol at a
|
||||
time, each with a separately reviewed policy schema and operational limits.
|
||||
- Keep omitted/`tcp` endpoints on raw L4 byte copy; never infer a native
|
||||
processor from traffic alone.
|
||||
- Test multi-message sessions, pre-dial denial, handshake-required dialing,
|
||||
per-command/query evaluation, middleware hooks, timeouts, and redaction.
|
||||
- Capability-gate policy that names a processor unavailable in the running
|
||||
proxy build.
|
||||
|
||||
## Phase 10 - Runtime Boundary
|
||||
|
||||
- Keep embedded and network-only supervisor modes as the migration baseline.
|
||||
- Define the proxy runtime API needed for a future standalone binary or
|
||||
sidecar: configured listeners, policy updates, provider credentials, token
|
||||
grants, middleware registry, gateway calls, telemetry, denial/activity
|
||||
events, and shutdown.
|
||||
- Advertise process-identity and protocol-processor capabilities. Reject policy
|
||||
that requires unavailable binary/path identity or processor support.
|
||||
- Represent intentional runtime identity unavailability separately from the
|
||||
existing endpoint-only mode and from lookup failure.
|
||||
- Add gateway capability negotiation if proxy and gateway versions can differ.
|
||||
|
||||
## Phase 11 - Final Cleanup
|
||||
|
||||
- Remove any compatibility query/evaluator retained for deterministic-decision
|
||||
rollback after its observation window closes.
|
||||
- Remove stale static `/etc/hosts`, iptables, or single-process assumptions from
|
||||
proxy design and architecture documentation as the corresponding later phase
|
||||
lands.
|
||||
- Keep adapter-specific response rendering and OCSF contracts at their protocol
|
||||
boundaries.
|
||||
|
||||
## Testing And Operational Validation
|
||||
|
||||
- Unit-test adapter intent construction, response rendering, explicit
|
||||
destination modes, identity evidence, and authorization precedence.
|
||||
- Integration-test destination validation across CONNECT and forward HTTP,
|
||||
then reuse the same suite for transparent TCP when Phase 8 lands.
|
||||
- Integration-test HTTP keep-alive/pipelining, REST, GraphQL, JSON-RPC, MCP,
|
||||
WebSocket, credentials, token grants, middleware, and TLS/raw-TCP selection.
|
||||
- Integration-test `inference.local`, `policy.local`, and metadata loopback body
|
||||
limits, timeouts, redaction, and local denial responses.
|
||||
- Compare OCSF fixtures before and after each migration subphase.
|
||||
- Exercise policy reload between L4 decision, endpoint materialization, relay
|
||||
startup, and long-lived per-request evaluation.
|
||||
- Add protocol-processor harness tests before adding Redis, Postgres, MySQL, or
|
||||
similar enforcement. Each concrete processor adds multi-message, handshake,
|
||||
timeout, denial, redaction, and middleware coverage.
|
||||
- Integration-test policy DNS filtering, denial without an upstream query,
|
||||
synthetic answer allocation, TTL and reuse quarantine, distinct mapping
|
||||
generations, policy-reload invalidation, atomic capture-rule updates,
|
||||
original-destination recovery, allowed-port correlation, connector
|
||||
restriction to pinned real addresses, and rejection of unrelated bare-IP
|
||||
connects.
|
||||
- Prove two names that resolve to the same real IP and port receive distinct
|
||||
correlations and cannot inherit each other's endpoint policy.
|
||||
- Test standalone/sidecar capability negotiation and prove missing identity or
|
||||
processor support fails during policy validation rather than broadening an
|
||||
allow at runtime.
|
||||
- Re-run the performance baseline after the compatibility envelope, after the
|
||||
single-decision query, and after relay consolidation. Treat reduced OPA calls
|
||||
as a measured result rather than an assumed benefit.
|
||||
- Back out structural phases by reverting their isolated commits. Keep shadow
|
||||
comparison and the legacy evaluator available through the deterministic
|
||||
cutover observation window.
|
||||
- Gate later transport/runtime phases independently so disabling policy DNS or
|
||||
transparent capture restores the existing explicit-proxy and bypass-reject
|
||||
behavior without reverting the compatibility foundation.
|
||||
@@ -0,0 +1,594 @@
|
||||
# Technical Design Appendix
|
||||
|
||||
This appendix carries implementation-level design details behind the main RFC.
|
||||
|
||||
## Existing Runtime Boundary
|
||||
|
||||
`openshell-supervisor-network::run::run_networking` is the current networking
|
||||
startup boundary. It builds policy-local context, waits for policy binary
|
||||
symlink resolution, creates the identity cache, writes the TLS CA, builds TLS
|
||||
state, resolves inference routes, wires provider credentials and token grants,
|
||||
and starts the proxy. The supervisor middleware work extends this boundary with
|
||||
middleware registry construction and reload behavior.
|
||||
|
||||
This is a useful outer boundary, but it is not yet the proxy adapter boundary.
|
||||
The proxy still needs internal `EgressIntent` and `EgressDecision` boundaries
|
||||
so CONNECT, forward HTTP, local routes, and future native TCP capture do not
|
||||
duplicate policy and relay orchestration. The first implementation milestone
|
||||
wires only current surfaces; later milestones add new adapters to the same
|
||||
contract.
|
||||
|
||||
## Shared Data Boundaries
|
||||
|
||||
### EgressIntent
|
||||
|
||||
`EgressIntent` is the normalized description of what userland is trying to do.
|
||||
|
||||
It should carry:
|
||||
|
||||
- entry transport: CONNECT, forward HTTP, transparent TCP, local HTTP, policy
|
||||
DNS, or metadata loopback;
|
||||
- requested destination host/port or captured original IP/port;
|
||||
- optional process identity inputs collected by the adapter/runtime;
|
||||
- optional first HTTP request for forward proxy traffic;
|
||||
- optional local service route;
|
||||
- policy generation and, for policy DNS/transparent TCP, a distinct DNS
|
||||
mapping generation and correlation handle.
|
||||
|
||||
Adapters build intents. They should not query endpoint metadata, select TLS
|
||||
mode, or select relays.
|
||||
|
||||
### EgressDecision
|
||||
|
||||
`EgressDecision` is the policy result consumed by validation and relay code.
|
||||
|
||||
It should carry:
|
||||
|
||||
- allow or deny;
|
||||
- one top-level policy generation used for every policy-derived field;
|
||||
- deterministic matched policy identifier;
|
||||
- whether the policy is user-authored, provider-derived, or local-service
|
||||
internal;
|
||||
- deterministic matched endpoint identifier and endpoint metadata;
|
||||
- process identity availability and any identity fields used for evaluation;
|
||||
- destination and allowed IP constraints;
|
||||
- TLS behavior;
|
||||
- protocol enforcement;
|
||||
- credential injection plan;
|
||||
- supervisor middleware plan;
|
||||
- the request-policy selection needed to create a pinned per-request L7
|
||||
evaluator when HTTP inspection is configured;
|
||||
- logging context and denial reason.
|
||||
|
||||
Relay code should read this decision. It should not query OPA again for
|
||||
endpoint metadata, TLS mode, allowed IPs, credential behavior, middleware
|
||||
selection, or relay selection. Long-lived HTTP relays still evaluate each
|
||||
request through the generation-pinned L7 evaluator carried in `RelayContext`;
|
||||
that is request authorization, not endpoint rematerialization. Later native
|
||||
protocol processors use the same pattern with a generation-pinned protocol
|
||||
evaluator for per-command or per-query decisions.
|
||||
|
||||
## Protocol Enforcement
|
||||
|
||||
Use a protocol enforcement value derived from endpoint policy:
|
||||
|
||||
| Policy protocol | Enforcement | Relay behavior |
|
||||
|-----------------|-------------|----------------|
|
||||
| omitted / `tcp` | None | L4 authorization plus byte relay, with optional HTTP sniff for credential injection |
|
||||
| `rest` | HTTP | HTTP request parser with REST rules, plus opt-in request-body and WebSocket text-frame credential rewrite |
|
||||
| `graphql` | HTTP | HTTP request parser with GraphQL-over-HTTP rules |
|
||||
| `json-rpc` | HTTP | HTTP request parser plus bounded JSON-RPC-over-HTTP method inspection |
|
||||
| `mcp` | HTTP | HTTP request parser plus bounded MCP Streamable HTTP method/tool inspection |
|
||||
| `websocket` | HTTP | HTTP upgrade policy followed by WebSocket frame policy or GraphQL-over-WebSocket policy |
|
||||
| future `redis`, `postgres`, `mysql`, ... | Protocol processor | Protocol-specific processor owns framing, middleware hooks, and the message loop |
|
||||
|
||||
`protocol: tcp` is effectively the default L4 mode. It should not run native
|
||||
protocol processors. Avoid using the term "provider" for processor concepts
|
||||
because providers are already a first-class credential and routing domain in
|
||||
OpenShell. Concrete native processors land after the shared dispatch contract.
|
||||
|
||||
## Suggested Types
|
||||
|
||||
The exact Rust shape can evolve, but the boundaries should look like this:
|
||||
|
||||
```rust
|
||||
enum EgressTransport {
|
||||
Connect,
|
||||
ForwardHttp,
|
||||
TransparentTcp,
|
||||
PolicyDns,
|
||||
LocalHttp,
|
||||
MetadataLoopback,
|
||||
}
|
||||
|
||||
struct EgressIntent {
|
||||
transport: EgressTransport,
|
||||
destination: RequestedDestination,
|
||||
process: ProcessIdentityEvidence,
|
||||
first_request: Option<ParsedHttpRequest>,
|
||||
local_route: Option<LocalRoute>,
|
||||
correlation: Option<ResolvedEndpointCorrelation>,
|
||||
}
|
||||
|
||||
struct EgressDecision {
|
||||
policy_generation: PolicyGeneration,
|
||||
outcome: PolicyOutcome,
|
||||
matched_policy: Option<MatchedPolicy>,
|
||||
endpoint: Option<MatchedEndpoint>,
|
||||
process: EvaluatedProcessIdentity,
|
||||
request_processing: RequestProcessingPlan,
|
||||
log_context: EgressLogContext,
|
||||
}
|
||||
|
||||
enum ProcessIdentityEvidence {
|
||||
Available(ProcessIdentity),
|
||||
Unavailable(ProcessIdentityUnavailableReason),
|
||||
}
|
||||
|
||||
enum ProcessIdentityUnavailableReason {
|
||||
EndpointOnlyMode,
|
||||
DeclaredRuntimeMode(RuntimeMode),
|
||||
UnsupportedPlatform,
|
||||
LookupFailed,
|
||||
}
|
||||
|
||||
struct EvaluatedProcessIdentity {
|
||||
evidence: ProcessIdentityEvidence,
|
||||
fields_used: Vec<ProcessIdentityField>,
|
||||
}
|
||||
|
||||
struct MatchedPolicy {
|
||||
id: PolicyId,
|
||||
source: PolicySource,
|
||||
}
|
||||
|
||||
enum PolicySource {
|
||||
User,
|
||||
ProviderDerived,
|
||||
LocalService,
|
||||
}
|
||||
|
||||
struct MatchedEndpoint {
|
||||
id: EndpointId,
|
||||
destination: DestinationValidationPlan,
|
||||
tls: TlsPolicy,
|
||||
enforcement: ProtocolEnforcement,
|
||||
}
|
||||
|
||||
struct DestinationValidationPlan {
|
||||
address_authorization: AddressAuthorization,
|
||||
}
|
||||
|
||||
enum AddressAuthorization {
|
||||
DefaultPublicOnly,
|
||||
ExplicitAllowedIps(Vec<IpNet>),
|
||||
ExactDeclaredHost,
|
||||
ImplicitIpLiteral(IpAddr),
|
||||
TrustedGatewayAlias { expected_ip: IpAddr },
|
||||
}
|
||||
|
||||
struct RequestProcessingPlan {
|
||||
middleware: SupervisorMiddlewarePlan,
|
||||
credentials: CredentialInjectionPlan,
|
||||
}
|
||||
|
||||
enum ProtocolEnforcement {
|
||||
None,
|
||||
Http(HttpL7Config),
|
||||
ProtocolProcessor(ProtocolProcessorConfig),
|
||||
}
|
||||
|
||||
enum HttpL7Protocol {
|
||||
Rest,
|
||||
Graphql,
|
||||
JsonRpc,
|
||||
Mcp,
|
||||
Websocket,
|
||||
}
|
||||
|
||||
struct HttpL7Config {
|
||||
protocol: HttpL7Protocol,
|
||||
path: EndpointPathScope,
|
||||
allow_encoded_slash: bool,
|
||||
enforcement_mode: L7EnforcementMode,
|
||||
websocket_credential_rewrite: bool,
|
||||
request_body_credential_rewrite: bool,
|
||||
websocket_graphql_policy: bool,
|
||||
graphql_max_body_bytes: usize,
|
||||
json_rpc_max_body_bytes: usize,
|
||||
mcp_strict_tool_names: bool,
|
||||
}
|
||||
|
||||
struct CredentialInjectionPlan {
|
||||
static_placeholders: StaticPlaceholderPlan,
|
||||
token_grant: Option<TokenGrantPlan>,
|
||||
}
|
||||
|
||||
struct StaticPlaceholderPlan {
|
||||
http_target_query_header: bool,
|
||||
rest_request_body: bool,
|
||||
websocket_text_frames: bool,
|
||||
}
|
||||
|
||||
struct TokenGrantPlan {
|
||||
provider_key: String,
|
||||
auth_style: TokenGrantAuthStyle,
|
||||
token_endpoint: String,
|
||||
}
|
||||
|
||||
struct SupervisorMiddlewarePlan {
|
||||
stages: Vec<SupervisorMiddlewareStage>,
|
||||
min_body_limit: Option<usize>,
|
||||
registry_generation: PolicyGeneration,
|
||||
}
|
||||
|
||||
struct SupervisorMiddlewareStage {
|
||||
policy_name: String,
|
||||
binding_id: String,
|
||||
operation: MiddlewareOperation,
|
||||
phase: MiddlewarePhase,
|
||||
order: i32,
|
||||
on_error: MiddlewareOnError,
|
||||
config: MiddlewareConfig,
|
||||
}
|
||||
|
||||
enum MiddlewareOperation {
|
||||
HttpRequest,
|
||||
Future(String),
|
||||
}
|
||||
|
||||
enum MiddlewarePhase {
|
||||
PreCredentials,
|
||||
Future(String),
|
||||
}
|
||||
|
||||
struct RelayContext {
|
||||
decision: EgressDecision,
|
||||
request_policy: Option<PinnedRequestPolicy>,
|
||||
protocol_policy: Option<PinnedProtocolPolicy>,
|
||||
connector: UpstreamConnector,
|
||||
deadlines: RelayDeadlines,
|
||||
telemetry: RelayTelemetry,
|
||||
}
|
||||
|
||||
struct ResolvedEndpointCorrelation {
|
||||
policy_generation: PolicyGeneration,
|
||||
mapping_generation: DnsMappingGeneration,
|
||||
mapping_id: DnsMappingId,
|
||||
synthetic_ip: IpAddr,
|
||||
}
|
||||
|
||||
struct PinnedRequestPolicy {
|
||||
generation: PolicyGeneration,
|
||||
evaluator: TunnelPolicyEngine,
|
||||
}
|
||||
|
||||
struct PinnedProtocolPolicy {
|
||||
generation: PolicyGeneration,
|
||||
evaluator: ProtocolPolicyEngine,
|
||||
}
|
||||
```
|
||||
|
||||
`UpstreamConnector` is the relay-owned dial boundary. It encapsulates the
|
||||
validated destination and lets relays or processors open an upstream connection
|
||||
only after current request or protocol policy allows it.
|
||||
|
||||
`DestinationValidationPlan` selects one current validation mode. All modes
|
||||
retain control-plane-port and cloud-metadata blocks. Default and explicit IP
|
||||
paths retain the always-blocked loopback, link-local, and unspecified-address
|
||||
checks. `ImplicitIpLiteral` is synthesized only for an explicitly declared IP
|
||||
host. `TrustedGatewayAlias` may accept the one runtime-discovered gateway IP but
|
||||
does not become a general private-address exemption.
|
||||
|
||||
`policy_generation`, the optional pinned request/protocol evaluators, endpoint
|
||||
metadata, and middleware selection must describe one policy snapshot.
|
||||
Authorization asserts that every sub-materialization used that generation. If
|
||||
the generation changes before relay startup, the adapter receives a
|
||||
stale-policy denial rather than a mixed decision.
|
||||
|
||||
## Process Identity Availability
|
||||
|
||||
Process identity is evidence, not a string to fabricate when lookup fails.
|
||||
Embedded mode normally populates binary, PID, ancestry, command-line path, and
|
||||
binary hash data. When binary identity is required, `LookupFailed` and
|
||||
`UnsupportedPlatform` remain denials. An explicitly configured endpoint-only
|
||||
runtime records `Unavailable(EndpointOnlyMode)` and keeps the current
|
||||
endpoint-only policy behavior. This RFC does not silently turn one state into
|
||||
the other or change the endpoint-only trust contract.
|
||||
|
||||
A future standalone or sidecar runtime that intentionally lacks local identity
|
||||
uses `DeclaredRuntimeMode`, not `EndpointOnlyMode`, and advertises that
|
||||
capability to policy validation. The runtime contract must define binary/path
|
||||
predicates as unavailable and reject incompatible policy before traffic starts,
|
||||
unless a later accepted policy design specifies a different fail-closed rule.
|
||||
|
||||
The decision records identity availability and fields used so OCSF logs and
|
||||
deny responses distinguish a binary policy denial, an identity lookup failure,
|
||||
and intentional endpoint-only evaluation. Tests must prove an empty synthetic
|
||||
`exec.path` cannot satisfy a binary-scoped rule while identity is required.
|
||||
Adding new identity-less deployment modes, or changing how binary predicates
|
||||
behave in endpoint-only mode, requires the capability work in the later runtime
|
||||
phase and cannot be smuggled into the compatibility refactor.
|
||||
|
||||
## Current Owners And Proposed Cleanup
|
||||
|
||||
| Current owner | Current responsibility | Proposed cleanup |
|
||||
|---------------|------------------------|------------------|
|
||||
| `openshell-sandbox` | Orchestrator, policy poll loop, denial/activity channels, metadata loopback startup, network-only lifecycle | Keep as orchestration; avoid embedding per-entry proxy policy decisions |
|
||||
| `openshell-supervisor-network::run` | Networking startup and handles | Become the stable runtime API for embedded and future standalone modes |
|
||||
| `openshell-supervisor-network::proxy` | CONNECT, forward HTTP, local route dispatch, destination validation, denial rendering | Split into adapters, authorization, destination, relay selection, and adapter response rendering |
|
||||
| `openshell-supervisor-network::opa` | Policy engine and Rego queries | Return deterministic `EgressDecision` data instead of separate policy and endpoint lookups |
|
||||
| `openshell-supervisor-network::l7` | REST, GraphQL, JSON-RPC, MCP, WebSocket, inference helpers, TLS, token grants | Keep as protocol/relay implementation behind shared relay boundaries |
|
||||
| `openshell-supervisor-network::policy_local` | `policy.local` state and routes | Model as a local adapter with explicit limits and proposal/wait behavior |
|
||||
| `openshell-supervisor-middleware` | Middleware registry, built-ins, service contract, and chain execution | Treat as a relay hook dependency selected by `EgressDecision`, not as adapter-specific policy logic |
|
||||
| `openshell-supervisor-process::netns` | nftables bypass rules and namespace helpers | Remain owner of bypass enforcement; coordinate future capture rules with network proxy mappings |
|
||||
| `openshell-supervisor-process::bypass_monitor` | nftables LOG parsing and OCSF bypass telemetry | Remain telemetry producer for bypass violations |
|
||||
| `openshell-core::secrets` and provider credential state | Static placeholder sources and dynamic credential metadata | Feed credential injection plans; do not leak secrets into decision logs |
|
||||
|
||||
## Policy DNS And Resolved TCP State
|
||||
|
||||
Policy DNS is query-driven rather than a static `/etc/hosts` snapshot.
|
||||
|
||||
1. Policy load registers eligible native TCP endpoint names.
|
||||
2. Userland performs a DNS lookup.
|
||||
3. Policy DNS checks whether the normalized name matches an endpoint whose
|
||||
transport and protocol contract enables native TCP through policy DNS in the
|
||||
current policy generation.
|
||||
4. An ineligible name receives a local policy-denial DNS response without an
|
||||
upstream query.
|
||||
5. Policy DNS resolves an eligible name through trusted upstream DNS and
|
||||
filters every answer through endpoint metadata and SSRF controls.
|
||||
6. The adapter allocates a supervisor-owned synthetic IP and creates an active
|
||||
mapping containing the synthetic IP, normalized name, endpoint identifier,
|
||||
allowed ports, validated real addresses, policy generation, distinct DNS
|
||||
mapping generation, opaque mapping ID, and expiration.
|
||||
7. The adapter publishes the mapping and capture state atomically before
|
||||
returning the synthetic IP to userland with a bounded TTL.
|
||||
8. Userland later calls `connect(synthetic_ip:port)`.
|
||||
9. Transparent TCP recovers the synthetic original destination and requires an
|
||||
unexpired exact mapping whose allowed ports contain the requested port.
|
||||
10. Normal egress authorization and relay selection run against a policy
|
||||
generation consistent with the mapping contract.
|
||||
11. The connector dials only a real address pinned in that mapping. It does not
|
||||
re-resolve the name independently at connect time.
|
||||
|
||||
The resolved endpoint store is active state produced by policy-eligible lookups
|
||||
and consumed by transparent TCP connects. Policy generation and DNS mapping
|
||||
generation are separate values: a DNS refresh can replace mappings without a
|
||||
policy reload, while a policy reload can invalidate mappings whose endpoint
|
||||
contract is no longer current. A captured connection with no mapping, a stale
|
||||
mapping, or a mismatched endpoint/port fails closed. An unrelated bare-IP
|
||||
connection cannot inherit a policy-DNS authorization merely because it targets
|
||||
a real IP present in the mapping store. Synthetic IPs are correlation handles,
|
||||
not upstream destinations, and must never be routed directly or reassigned
|
||||
while a stale answer could still refer to the prior mapping.
|
||||
|
||||
The mapping is sandbox-scoped rather than process-scoped. Process identity is
|
||||
looked up and evaluated independently when the captured TCP connection is
|
||||
authorized. It is not used to join DNS and TCP because name resolution may be
|
||||
cached, delegated to a resolver helper, or consumed by a different process, and
|
||||
multiple names resolved by one process may share a real address and port.
|
||||
|
||||
## nftables Boundary
|
||||
|
||||
Current main uses nftables, not iptables, for sandbox network bypass
|
||||
enforcement. The installed `inet` table accepts traffic to the sandbox proxy,
|
||||
loopback, and established/related flows, then rejects and optionally logs other
|
||||
TCP/UDP traffic. The bypass monitor reads those log lines and emits OCSF
|
||||
network and detection events.
|
||||
|
||||
Transparent TCP capture builds on this same nftables substrate in a later
|
||||
feature phase:
|
||||
|
||||
- capture rules run before the generic bypass reject rules;
|
||||
- capture rules are scoped to active synthetic-IP and allowed-port mappings;
|
||||
- mapping and capture-rule updates are atomic from the adapter's perspective;
|
||||
- direct external DNS remains blocked; policy DNS is the sandbox resolver, and
|
||||
DNS-over-HTTPS remains ordinary policy-controlled HTTPS egress;
|
||||
- reject/log rules remain the fallback for unmatched TCP/UDP egress;
|
||||
- VM or Podman driver nftables rules are infrastructure NAT/isolation and are
|
||||
not the proxy policy enforcement point.
|
||||
|
||||
The initial CONNECT/forward refactor does not change the installed table. This
|
||||
section defines the consumer contract that the shared adapter and decision
|
||||
boundaries must support when transparent capture lands.
|
||||
|
||||
## Endpoint Selection And OPA
|
||||
|
||||
Today the matched policy name, L7 candidates, first TLS/`allowed_ips` endpoint,
|
||||
and exact-declared-host signal are selected through independent rules. OPA/Rego
|
||||
should return policy and endpoint metadata through one deterministic
|
||||
authorization result. It should not let those fields describe different
|
||||
matches.
|
||||
|
||||
Two acceptable approaches:
|
||||
|
||||
- Reject overlapping endpoint metadata at load or merge time.
|
||||
- Define a single deterministic precedence key and use it for both policy name
|
||||
and endpoint metadata.
|
||||
|
||||
Endpoint metadata query failures should fail closed when metadata is required
|
||||
for the selected endpoint. They should not silently downgrade to L4 behavior.
|
||||
The top-level decision generation must also match every policy-derived field;
|
||||
reload during materialization yields a stale decision instead of mixing
|
||||
generations.
|
||||
|
||||
This semantic cutover is separate from introducing the Rust types. The new
|
||||
query first runs in shadow mode beside the legacy queries, records audit-safe
|
||||
mismatches through internal telemetry, and preserves legacy enforcement. After
|
||||
the precedence rule is accepted and mismatch cases are understood, a dedicated
|
||||
change switches the authoritative result and retains the legacy evaluator long
|
||||
enough for immediate rollback.
|
||||
|
||||
Provider-derived policies use a reserved rule-name namespace. The gateway and
|
||||
sandbox sync should prevent user-authored `_provider_*` rules, and
|
||||
`policy.local` proposal surfaces should not expose provider-derived rules as
|
||||
editable user policy. `EgressDecision` should still identify provider-derived
|
||||
matches for logging and debugging.
|
||||
|
||||
## Credential Injection Boundary
|
||||
|
||||
Credential injection belongs in the HTTP/WebSocket relay after policy allow and
|
||||
supervisor middleware, and before upstream write.
|
||||
|
||||
1. Authorization selects the endpoint and computes a credential injection plan.
|
||||
2. Supervisor middleware runs on the admitted request before credentials are
|
||||
visible.
|
||||
3. If middleware replaces the body, the relay re-parses body-dependent
|
||||
protocol inputs and re-evaluates request policy.
|
||||
4. The HTTP relay resolves credentials only when it still has an allowed
|
||||
request under the endpoint's enforcement mode.
|
||||
5. Static placeholder values are resolved and redacted from logs.
|
||||
6. Endpoint-bound token grants obtain or reuse a dynamic access token.
|
||||
7. The final upstream request or WebSocket frame is rewritten immediately
|
||||
before write.
|
||||
|
||||
Both L4-only HTTP and HTTP-inspected paths can inject credentials. The
|
||||
difference is whether REST, GraphQL, or WebSocket policy is evaluated before
|
||||
the rewrite.
|
||||
|
||||
Credential rewrite slots should be explicit:
|
||||
|
||||
- request target, query values, and headers for HTTP-family traffic;
|
||||
- REST request bodies only when `request_body_credential_rewrite` is enabled;
|
||||
- client-to-server WebSocket text frames only when
|
||||
`websocket_credential_rewrite` is enabled;
|
||||
- GraphQL-over-WebSocket connection/control messages when they are carried in
|
||||
text frames and the endpoint enables the WebSocket rewrite path;
|
||||
- token grant headers for endpoint-bound provider credentials.
|
||||
|
||||
Request-body rewrite is REST-only. It should buffer bounded UTF-8 textual
|
||||
bodies, including JSON, form-url-encoded, and `text/*`, recompute
|
||||
`Content-Length`, preserve unsupported bodies that contain no reserved
|
||||
credential markers, and fail closed when a reserved placeholder cannot be
|
||||
resolved safely. Binary WebSocket frames are not rewritten.
|
||||
|
||||
Token grants are dynamic credential injection. They use provider metadata to
|
||||
request a SPIFFE JWT-SVID, exchange it for an OAuth2 access token, cache the
|
||||
token, and inject either an `Authorization: Bearer` header or a configured
|
||||
custom header. Token grant failures should return a local relay error and must
|
||||
not forward the request upstream.
|
||||
|
||||
Middleware-transformed content should be treated as untrusted input from a
|
||||
credential perspective. External middleware must not receive OpenShell-managed
|
||||
credentials, and it should not be able to synthesize new reserved credential
|
||||
placeholders that OpenShell later resolves into secrets. Unless a future hook
|
||||
is explicitly built-in-only and credential-capable, the relay should fail
|
||||
closed or strip newly introduced reserved placeholders before static
|
||||
placeholder rewrite and token grant injection.
|
||||
|
||||
## Supervisor Middleware Boundary
|
||||
|
||||
Supervisor middleware is a typed relay hook, not a replacement for protocol
|
||||
framing. The relay or protocol processor must first parse enough structure to
|
||||
construct the operation-specific middleware input.
|
||||
|
||||
For v1, the operation is `HTTP_REQUEST / PRE_CREDENTIALS`:
|
||||
|
||||
1. Network policy, destination validation, and request policy admit the
|
||||
request.
|
||||
2. The HTTP relay selects the middleware chain from the request processing
|
||||
plan.
|
||||
3. The relay buffers the request body within the smallest selected stage limit.
|
||||
4. The chain evaluates in deterministic order.
|
||||
5. A deny short-circuits before credential injection or upstream write.
|
||||
6. An allow can replace the request body, add approved headers, emit findings,
|
||||
and pass metadata forward.
|
||||
7. When the body changes, the relay re-parses and re-evaluates body-dependent
|
||||
request policy inputs.
|
||||
8. The transformed request enters credential injection and upstream write only
|
||||
after that re-evaluation admits it under the endpoint's enforcement mode.
|
||||
|
||||
The re-evaluation uses the original request method, path, and query because v1
|
||||
middleware cannot mutate them. It re-derives the GraphQL operation, JSON-RPC
|
||||
method, and MCP method or tool name from the transformed body. A policy mismatch
|
||||
preserves the endpoint's audit or enforce behavior. A malformed or
|
||||
unclassifiable transformed protocol body fails closed in both modes because the
|
||||
relay can no longer prove which operation it would forward.
|
||||
|
||||
Middleware selection is independent from the matched endpoint policy. It is a
|
||||
request processing plan selected by admitted destination host, order, and
|
||||
binding metadata. The decision boundary should materialize it with the same
|
||||
policy generation used for endpoint selection so a long-lived tunnel cannot mix
|
||||
old endpoint policy with a new middleware registry.
|
||||
|
||||
V1 middleware can inspect WebSocket upgrade requests because those are HTTP
|
||||
requests. It does not inspect post-upgrade WebSocket frames. A future frame
|
||||
hook should be a separate operation such as `WEBSOCKET_MESSAGE /
|
||||
BEFORE_FORWARD` owned by the WebSocket relay.
|
||||
|
||||
## Protocol Processor Boundary
|
||||
|
||||
Protocol processors operate on streams owned by the relay.
|
||||
|
||||
- HTTP parsing converts bytes into request metadata, evaluates request policy,
|
||||
runs the `HTTP_REQUEST / PRE_CREDENTIALS` middleware hook when configured,
|
||||
and loops for keep-alive or pipelined requests.
|
||||
- JSON-RPC and MCP processing are HTTP L7 processors: they parse bounded
|
||||
JSON-RPC-over-HTTP request bodies after HTTP parsing and before upstream
|
||||
forwarding. Generic JSON-RPC policy matches methods; MCP policy can also
|
||||
match `tools/call` tool names.
|
||||
- WebSocket parsing starts only after an allowed HTTP upgrade. It validates the
|
||||
handshake/frame stream and owns client-to-server text-frame inspection when
|
||||
credential rewrite, transport message policy, GraphQL-over-WebSocket policy,
|
||||
or compression handling is configured.
|
||||
- Native TCP protocol processors read client and upstream streams as needed and
|
||||
own their message loop.
|
||||
- A protocol processor can deny before dialing, dial for a server handshake, or
|
||||
keep evaluating commands or queries throughout the session.
|
||||
- A protocol processor may be in-tree, middleware-backed, or a hybrid where
|
||||
in-tree framing exposes typed middleware operations for content evaluation.
|
||||
|
||||
HTTP and WebSocket relays receive the generation-pinned request evaluator
|
||||
because request policy must continue throughout long-lived sessions. No
|
||||
processor rematerializes endpoint, TLS, allowed-IP, credential, or middleware
|
||||
selection. This avoids a separate dial-strategy enum: each processor knows
|
||||
which protocol milestone is sufficient to call the validated connector.
|
||||
|
||||
## Local Service Adapter Boundary
|
||||
|
||||
Local services are network surfaces but not normal external egress:
|
||||
|
||||
- `inference.local` terminates local client traffic, validates known inference
|
||||
routes, strips caller auth, injects provider routing/auth, and applies
|
||||
streaming or buffered limits based on route type.
|
||||
- `policy.local` serves policy snapshots, denial summaries, proposal
|
||||
submission, and proposal wait. It should never expose secrets or provider
|
||||
rules as editable policy.
|
||||
- Metadata loopback serves provider metadata credentials for SDKs that bypass
|
||||
HTTP proxy variables. It should use the same provider credential state and
|
||||
redaction discipline as other credential paths.
|
||||
|
||||
These adapters may call gateway APIs or local credential helpers, but they
|
||||
should not bypass policy and credential invariants that apply to external
|
||||
egress.
|
||||
|
||||
Issue [#1633](https://github.com/NVIDIA/OpenShell/issues/1633) is a prospective
|
||||
consumer of these boundaries, not a feature defined by this RFC. A
|
||||
policy-declared host-local endpoint should use an explicit local-routing adapter
|
||||
or destination mode; it must not become a general loopback exemption in the
|
||||
external destination validator. Its feature design still needs to choose the
|
||||
policy surface (reserved hostname versus endpoint flag), define authorization
|
||||
before the supervisor connects to host loopback, and specify driver/runtime
|
||||
capabilities. That work can reuse `EgressIntent`, adapter-specific responses,
|
||||
and the unopened connector boundary without changing this RFC's compatibility
|
||||
milestone.
|
||||
|
||||
## Timeout And Resource Ownership
|
||||
|
||||
| Owner | Resource |
|
||||
|-------|----------|
|
||||
| Adapter | Client-side parse timeout and adapter-specific deny response |
|
||||
| Authorization | OPA deadline and policy evaluation telemetry |
|
||||
| Destination validator | DNS timeout, allowed IP checks, SSRF checks, control-plane port checks |
|
||||
| TLS terminator | Client TLS handshake timeout and certificate selection |
|
||||
| HTTP relay | Per-request read/write deadlines, body caps, request-body rewrite caps, upstream reuse |
|
||||
| WebSocket relay | Upgrade validation, frame limits, text-frame rewrite, compression limits, message policy |
|
||||
| TCP relay | Byte-copy idle timeout and half-close handling |
|
||||
| Protocol processor | Protocol message timeouts, middleware hook timeouts, and processor-specific limits |
|
||||
| Local service adapter | Local route body limits, response caps, gateway call timeout |
|
||||
| Token grant resolver | SPIFFE Workload API timeout, token endpoint timeout, cache TTL |
|
||||
| Middleware runner | Service timeout, body cap, failure policy, registry generation |
|
||||
|
||||
Timeouts should be recorded in telemetry at the owner boundary that can explain
|
||||
the failure.
|
||||
Generated
+41
-170
@@ -44,42 +44,10 @@
|
||||
"integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
||||
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
|
||||
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/regexp-to-ast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
|
||||
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
|
||||
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
|
||||
"license": "Apache-2.0"
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
|
||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
@@ -209,12 +177,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
|
||||
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
|
||||
"license": "MIT",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
|
||||
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
|
||||
"dependencies": {
|
||||
"langium": "^4.0.0"
|
||||
"@chevrotain/types": "~11.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
@@ -533,34 +500,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/regexp-to-ast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0",
|
||||
"@chevrotain/utils": "12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain-allstar": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
|
||||
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chevrotain": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -617,10 +556,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
"version": "3.33.2",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz",
|
||||
"integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==",
|
||||
"license": "MIT",
|
||||
"version": "3.34.0",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
|
||||
"integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
@@ -1255,17 +1193,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz",
|
||||
"integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -1363,10 +1305,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"license": "MIT",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
@@ -1509,24 +1450,6 @@
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
|
||||
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chevrotain/regexp-to-ast": "~12.0.0",
|
||||
"chevrotain": "~12.0.0",
|
||||
"chevrotain-allstar": "~0.4.1",
|
||||
"vscode-languageserver": "~9.0.1",
|
||||
"vscode-languageserver-textdocument": "~1.0.11",
|
||||
"vscode-uri": "~3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.10.0",
|
||||
"npm": ">=10.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/layout-base": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
|
||||
@@ -1567,32 +1490,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
|
||||
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
|
||||
"license": "MIT",
|
||||
"version": "11.16.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz",
|
||||
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@mermaid-js/parser": "^1.1.0",
|
||||
"@mermaid-js/parser": "^1.2.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@upsetjs/venn.js": "^2.0.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape": "^3.33.3",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"katex": "^0.16.25",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.3.3",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"katex": "^0.16.45",
|
||||
"khroma": "^2.1.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"marked": "^16.3.0",
|
||||
"roughjs": "^4.6.6",
|
||||
"stylis": "^4.3.6",
|
||||
"ts-dedent": "^2.2.0",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
@@ -1833,67 +1755,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
|
||||
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-languageserver-protocol": "3.17.5"
|
||||
},
|
||||
"bin": {
|
||||
"installServerIntoExtension": "bin/installServerIntoExtension"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
@@ -1951,10 +1823,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
|
||||
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Code generation for the Go SDK. The proto module boundary and validation
|
||||
# policy live in the repo-level buf.yaml; this template only drives generation.
|
||||
# buf compiles the module with its own compiler and runs protoc-gen-go /
|
||||
# protoc-gen-go-grpc from mise-managed binaries. Limited to the client-surface
|
||||
# closure (openshell, datamodel, sandbox, options); well-known types resolve
|
||||
# through google.golang.org/protobuf and are not generated.
|
||||
version: v2
|
||||
|
||||
inputs:
|
||||
- directory: ../../proto
|
||||
paths:
|
||||
- ../../proto/openshell.proto
|
||||
- ../../proto/datamodel.proto
|
||||
- ../../proto/sandbox.proto
|
||||
- ../../proto/options.proto
|
||||
|
||||
plugins:
|
||||
- local: protoc-gen-go
|
||||
out: .
|
||||
opt:
|
||||
- module=github.com/NVIDIA/OpenShell/sdk/go
|
||||
- Mopenshell.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1
|
||||
- Mdatamodel.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1
|
||||
- Msandbox.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1
|
||||
- Moptions.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1
|
||||
- local: protoc-gen-go-grpc
|
||||
out: .
|
||||
opt:
|
||||
- module=github.com/NVIDIA/OpenShell/sdk/go
|
||||
- Mopenshell.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1
|
||||
- Mdatamodel.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1
|
||||
- Msandbox.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1
|
||||
- Moptions.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1
|
||||
@@ -0,0 +1,22 @@
|
||||
module github.com/NVIDIA/OpenShell/sdk/go
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// AuthProvider supplies per-RPC credentials. It implements the
|
||||
// grpc credentials.PerRPCCredentials interface.
|
||||
type AuthProvider = types.AuthProvider
|
||||
|
||||
type noAuth struct{}
|
||||
|
||||
// NoAuth returns an AuthProvider that sends no credentials.
|
||||
func NoAuth() AuthProvider {
|
||||
return &noAuth{}
|
||||
}
|
||||
|
||||
func (n *noAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (n *noAuth) RequireTransportSecurity() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type staticToken struct {
|
||||
token string
|
||||
}
|
||||
|
||||
// StaticToken returns an AuthProvider that sends a fixed Bearer token.
|
||||
func StaticToken(token string) AuthProvider {
|
||||
return &staticToken{token: token}
|
||||
}
|
||||
|
||||
func (s *staticToken) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
|
||||
return map[string]string{
|
||||
"authorization": "Bearer " + s.token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *staticToken) RequireTransportSecurity() bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// extraHeadersAuth wraps a base AuthProvider with additional static headers
|
||||
// that are merged into every GetRequestMetadata call. Extra headers take
|
||||
// precedence over base headers on key collision (case-insensitive).
|
||||
type extraHeadersAuth struct {
|
||||
base types.AuthProvider
|
||||
headers map[string]string // keys already lowercase, empty values filtered out
|
||||
}
|
||||
|
||||
// WithExtraHeaders wraps base with additional per-RPC headers. Keys are
|
||||
// normalized to lowercase per HTTP/2 (RFC 9113). Empty-string values are
|
||||
// silently dropped. The headers map is deep-copied at construction time,
|
||||
// so later mutations to the caller's map have no effect.
|
||||
//
|
||||
// Returns an error if base is nil or if headers is nil, empty, or contains
|
||||
// only empty-string values.
|
||||
func WithExtraHeaders(base AuthProvider, headers map[string]string) (AuthProvider, error) {
|
||||
if base == nil {
|
||||
return nil, errors.New("base auth provider must not be nil")
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
return nil, errors.New("headers must not be nil or empty")
|
||||
}
|
||||
|
||||
// Deep-copy and normalize: lowercase keys, skip empty values.
|
||||
normalized := make(map[string]string, len(headers))
|
||||
for k, v := range headers {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
normalized[strings.ToLower(k)] = v
|
||||
}
|
||||
|
||||
if len(normalized) == 0 {
|
||||
return nil, errors.New("headers must contain at least one non-empty value")
|
||||
}
|
||||
|
||||
return &extraHeadersAuth{
|
||||
base: base,
|
||||
headers: normalized,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetRequestMetadata merges base metadata with extra headers. Extra headers
|
||||
// win on key collision because they are applied after the base metadata.
|
||||
func (e *extraHeadersAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
|
||||
baseMD, err := e.base.GetRequestMetadata(ctx, uri...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Start with base metadata (may be nil for NoAuth).
|
||||
// Normalize base keys to lowercase for case-insensitive collision.
|
||||
merged := make(map[string]string, len(baseMD)+len(e.headers))
|
||||
for k, v := range baseMD {
|
||||
merged[strings.ToLower(k)] = v
|
||||
}
|
||||
// Extra headers overwrite base on collision.
|
||||
maps.Copy(merged, e.headers)
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// RequireTransportSecurity delegates to the base auth provider.
|
||||
func (e *extraHeadersAuth) RequireTransportSecurity() bool {
|
||||
return e.base.RequireTransportSecurity()
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithExtraHeaders_NilBase(t *testing.T) {
|
||||
_, err := WithExtraHeaders(nil, map[string]string{"x-key": "val"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "base")
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_NilHeaders(t *testing.T) {
|
||||
_, err := WithExtraHeaders(NoAuth(), nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "headers")
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_EmptyHeaders(t *testing.T) {
|
||||
_, err := WithExtraHeaders(NoAuth(), map[string]string{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "headers")
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_AllEmptyValues(t *testing.T) {
|
||||
// All values are empty strings, so after filtering, headers map is empty.
|
||||
_, err := WithExtraHeaders(NoAuth(), map[string]string{"x-key": ""})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "headers")
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_MergesWithBase(t *testing.T) {
|
||||
base := StaticToken("my-token")
|
||||
auth, err := WithExtraHeaders(base, map[string]string{
|
||||
"x-proxy-key": "proxy-secret",
|
||||
"x-tenant-id": "acme-corp",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
md, err := auth.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Bearer my-token", md["authorization"])
|
||||
assert.Equal(t, "proxy-secret", md["x-proxy-key"])
|
||||
assert.Equal(t, "acme-corp", md["x-tenant-id"])
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_ExtraPrecedenceOnCollision(t *testing.T) {
|
||||
base := StaticToken("my-token")
|
||||
auth, err := WithExtraHeaders(base, map[string]string{
|
||||
"authorization": "Custom override-token",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
md, err := auth.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Extra header wins over base.
|
||||
assert.Equal(t, "Custom override-token", md["authorization"])
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_CaseInsensitiveCollision(t *testing.T) {
|
||||
base := StaticToken("my-token")
|
||||
// "Authorization" with uppercase should still override "authorization".
|
||||
auth, err := WithExtraHeaders(base, map[string]string{
|
||||
"Authorization": "Custom override-token",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
md, err := auth.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Custom override-token", md["authorization"])
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_EmptyValueSkipped(t *testing.T) {
|
||||
base := StaticToken("my-token")
|
||||
auth, err := WithExtraHeaders(base, map[string]string{
|
||||
"x-proxy-key": "proxy-secret",
|
||||
"x-empty": "", // Should be silently skipped.
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
md, err := auth.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "proxy-secret", md["x-proxy-key"])
|
||||
_, hasEmpty := md["x-empty"]
|
||||
assert.False(t, hasEmpty, "empty-string header values should be skipped")
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_RequireTransportSecurity_Delegates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
base AuthProvider
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "delegates to NoAuth (false)",
|
||||
base: NoAuth(),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "delegates to StaticToken (true)",
|
||||
base: StaticToken("tok"),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
auth, err := WithExtraHeaders(tt.base, map[string]string{"x-key": "val"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, auth.RequireTransportSecurity())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_WithNoAuth(t *testing.T) {
|
||||
auth, err := WithExtraHeaders(NoAuth(), map[string]string{
|
||||
"x-proxy-key": "proxy-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
md, err := auth.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// NoAuth returns nil metadata, extra headers should still appear.
|
||||
assert.Equal(t, "proxy-secret", md["x-proxy-key"])
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_BaseError_Propagated(t *testing.T) {
|
||||
base := &errAuth{err: errors.New("auth failure")}
|
||||
auth, err := WithExtraHeaders(base, map[string]string{"x-key": "val"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = auth.GetRequestMetadata(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "auth failure")
|
||||
}
|
||||
|
||||
func TestWithExtraHeaders_DeepCopiesHeaders(t *testing.T) {
|
||||
original := map[string]string{
|
||||
"x-key": "original-value",
|
||||
}
|
||||
auth, err := WithExtraHeaders(NoAuth(), original)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mutate the original map after construction.
|
||||
original["x-key"] = "mutated-value"
|
||||
|
||||
md, err := auth.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// The wrapper should use the value at construction time, not the mutated value.
|
||||
assert.Equal(t, "original-value", md["x-key"])
|
||||
}
|
||||
|
||||
// errAuth is a test helper that always returns an error.
|
||||
type errAuth struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
|
||||
return nil, e.err
|
||||
}
|
||||
|
||||
func (e *errAuth) RequireTransportSecurity() bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
const defaultLeeway = 10 * time.Second
|
||||
|
||||
var errNilTokenSource = errors.New("openshell: TokenSource must not be nil")
|
||||
|
||||
// RefreshOption configures the behavior of RefreshableToken.
|
||||
type RefreshOption func(*refreshConfig)
|
||||
|
||||
type refreshConfig struct {
|
||||
leeway time.Duration
|
||||
logger types.Logger
|
||||
}
|
||||
|
||||
func defaultRefreshConfig() refreshConfig {
|
||||
return refreshConfig{
|
||||
leeway: defaultLeeway,
|
||||
}
|
||||
}
|
||||
|
||||
// WithLeeway sets the duration before token expiry at which a proactive
|
||||
// refresh is triggered. Default is 10 seconds.
|
||||
func WithLeeway(d time.Duration) RefreshOption {
|
||||
return func(c *refreshConfig) {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
c.leeway = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets the logger used for stale-token fallback warnings.
|
||||
// When not set, warnings are silently dropped.
|
||||
func WithLogger(l types.Logger) RefreshOption {
|
||||
return func(c *refreshConfig) {
|
||||
c.logger = l
|
||||
}
|
||||
}
|
||||
|
||||
type refreshableAuth struct {
|
||||
source oauth2.TokenSource
|
||||
mu sync.RWMutex
|
||||
tok *oauth2.Token
|
||||
leeway time.Duration
|
||||
logger types.Logger
|
||||
}
|
||||
|
||||
func (r *refreshableAuth) isTokenValid() bool {
|
||||
if r.tok == nil {
|
||||
return false
|
||||
}
|
||||
if r.tok.Expiry.IsZero() {
|
||||
return true
|
||||
}
|
||||
return time.Now().Before(r.tok.Expiry.Add(-r.leeway))
|
||||
}
|
||||
|
||||
func (r *refreshableAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
|
||||
// Fast path: RLock, return cached token if valid.
|
||||
r.mu.RLock()
|
||||
if r.isTokenValid() {
|
||||
tok := r.tok.AccessToken
|
||||
r.mu.RUnlock()
|
||||
return map[string]string{"authorization": "Bearer " + tok}, nil
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
// Slow path: Lock, re-check, fetch if still stale.
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.isTokenValid() {
|
||||
return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil
|
||||
}
|
||||
|
||||
newTok, err := r.source.Token()
|
||||
if err != nil {
|
||||
if r.tok != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Error(err, "token refresh failed, using cached token")
|
||||
}
|
||||
return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if newTok == nil {
|
||||
if r.tok != nil {
|
||||
if r.logger != nil {
|
||||
r.logger.Error(errors.New("token source returned nil token"), "token refresh returned nil, using cached token")
|
||||
}
|
||||
return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil
|
||||
}
|
||||
return nil, errors.New("openshell: token source returned nil token")
|
||||
}
|
||||
|
||||
r.tok = newTok
|
||||
return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil
|
||||
}
|
||||
|
||||
func (r *refreshableAuth) RequireTransportSecurity() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// RefreshableToken returns an AuthProvider that caches tokens from src
|
||||
// and refreshes them before expiry. Concurrent callers share a single
|
||||
// refresh call (coalesced via RWMutex double-checked locking).
|
||||
func RefreshableToken(src oauth2.TokenSource, opts ...RefreshOption) (AuthProvider, error) {
|
||||
if src == nil {
|
||||
return nil, errNilTokenSource
|
||||
}
|
||||
|
||||
cfg := defaultRefreshConfig()
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
|
||||
return &refreshableAuth{
|
||||
source: src,
|
||||
leeway: cfg.leeway,
|
||||
logger: cfg.logger,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// mockTokenSource implements oauth2.TokenSource for testing.
|
||||
type mockTokenSource struct {
|
||||
mu sync.Mutex
|
||||
tokenFunc func() (*oauth2.Token, error)
|
||||
callCount int
|
||||
}
|
||||
|
||||
func (m *mockTokenSource) Token() (*oauth2.Token, error) {
|
||||
m.mu.Lock()
|
||||
m.callCount++
|
||||
m.mu.Unlock()
|
||||
return m.tokenFunc()
|
||||
}
|
||||
|
||||
func (m *mockTokenSource) calls() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.callCount
|
||||
}
|
||||
|
||||
// --- Phase 2 tests: constructor validation ---
|
||||
|
||||
func TestRefreshableToken_NilSource(t *testing.T) {
|
||||
_, err := RefreshableToken(nil)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "openshell: TokenSource must not be nil", err.Error())
|
||||
}
|
||||
|
||||
func TestRefreshableToken_ValidSource(t *testing.T) {
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "tok", Expiry: time.Now().Add(time.Hour)}, nil
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, provider)
|
||||
}
|
||||
|
||||
// --- Phase 3 / US1 tests: automatic token refresh ---
|
||||
|
||||
func TestGetRequestMetadata_FirstCallFetchesToken(t *testing.T) {
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "fresh-token", Expiry: time.Now().Add(time.Hour)}, nil
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src)
|
||||
require.NoError(t, err)
|
||||
|
||||
md, err := provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer fresh-token", md["authorization"])
|
||||
assert.Equal(t, 1, src.calls())
|
||||
}
|
||||
|
||||
func TestGetRequestMetadata_CachedTokenNoExtraCall(t *testing.T) {
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "cached-token", Expiry: time.Now().Add(time.Hour)}, nil
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
md, err := provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer cached-token", md["authorization"])
|
||||
assert.Equal(t, 1, src.calls(), "second call should use cache, not invoke TokenSource")
|
||||
}
|
||||
|
||||
func TestGetRequestMetadata_RefreshesExpiredToken(t *testing.T) {
|
||||
var callNum atomic.Int32
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
n := callNum.Add(1)
|
||||
if n == 1 {
|
||||
return &oauth2.Token{AccessToken: "old", Expiry: time.Now().Add(-time.Minute)}, nil
|
||||
}
|
||||
return &oauth2.Token{AccessToken: "new", Expiry: time.Now().Add(time.Hour)}, nil
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src, WithLeeway(0))
|
||||
require.NoError(t, err)
|
||||
|
||||
// First call gets the expired token, which is immediately stale.
|
||||
md, err := provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer old", md["authorization"])
|
||||
|
||||
// Second call should trigger a refresh since the cached token is expired.
|
||||
md, err = provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer new", md["authorization"])
|
||||
assert.Equal(t, 2, src.calls())
|
||||
}
|
||||
|
||||
func TestGetRequestMetadata_ConcurrentSingleFlight(t *testing.T) {
|
||||
var fetchCount atomic.Int32
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
fetchCount.Add(1)
|
||||
time.Sleep(10 * time.Millisecond) // simulate slow token fetch
|
||||
return &oauth2.Token{AccessToken: "shared-token", Expiry: time.Now().Add(time.Hour)}, nil
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src)
|
||||
require.NoError(t, err)
|
||||
|
||||
const goroutines = 1000
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
results := make([]string, goroutines)
|
||||
errs := make([]error, goroutines)
|
||||
|
||||
for i := range goroutines {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
md, e := provider.GetRequestMetadata(context.Background())
|
||||
errs[idx] = e
|
||||
if md != nil {
|
||||
results[idx] = md["authorization"]
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i := range goroutines {
|
||||
require.NoError(t, errs[i], "goroutine %d failed", i)
|
||||
assert.Equal(t, "Bearer shared-token", results[i], "goroutine %d got wrong token", i)
|
||||
}
|
||||
assert.Equal(t, int32(1), fetchCount.Load(), "expected exactly 1 TokenSource.Token() call, got %d", fetchCount.Load())
|
||||
}
|
||||
|
||||
func TestRefreshableAuth_RequireTransportSecurity(t *testing.T) {
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "t"}, nil
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, provider.RequireTransportSecurity())
|
||||
}
|
||||
|
||||
// --- Phase 4 / US2 tests: graceful degradation ---
|
||||
|
||||
func TestGetRequestMetadata_RefreshFailureReturnsStaleCachedToken(t *testing.T) {
|
||||
var callNum atomic.Int32
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
n := callNum.Add(1)
|
||||
if n == 1 {
|
||||
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("idp unavailable")
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src, WithLeeway(0))
|
||||
require.NoError(t, err)
|
||||
|
||||
// First call succeeds but returns already-expired token.
|
||||
_, err = provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second call: refresh fails, should return stale token.
|
||||
md, err := provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer stale", md["authorization"])
|
||||
}
|
||||
|
||||
func TestGetRequestMetadata_RefreshFailureLogsWarning(t *testing.T) {
|
||||
var callNum atomic.Int32
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
n := callNum.Add(1)
|
||||
if n == 1 {
|
||||
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("idp unavailable")
|
||||
},
|
||||
}
|
||||
|
||||
logger := &captureLogger{}
|
||||
provider, err := RefreshableToken(src, WithLeeway(0), WithLogger(logger))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _ = provider.GetRequestMetadata(context.Background())
|
||||
_, _ = provider.GetRequestMetadata(context.Background())
|
||||
|
||||
require.Len(t, logger.errors, 1)
|
||||
assert.Contains(t, logger.errors[0].msg, "token refresh failed")
|
||||
}
|
||||
|
||||
func TestGetRequestMetadata_RefreshFailureNoCachedTokenReturnsError(t *testing.T) {
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
return nil, fmt.Errorf("idp unavailable")
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = provider.GetRequestMetadata(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "idp unavailable")
|
||||
}
|
||||
|
||||
func TestGetRequestMetadata_RefreshFailureNoLoggerNoPanic(t *testing.T) {
|
||||
var callNum atomic.Int32
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
n := callNum.Add(1)
|
||||
if n == 1 {
|
||||
return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("idp unavailable")
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src, WithLeeway(0))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _ = provider.GetRequestMetadata(context.Background())
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
_, _ = provider.GetRequestMetadata(context.Background())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Phase 5 / US3 tests: configurable leeway ---
|
||||
|
||||
func TestGetRequestMetadata_DefaultLeewayTriggersRefresh(t *testing.T) {
|
||||
var callNum atomic.Int32
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
n := callNum.Add(1)
|
||||
return &oauth2.Token{
|
||||
AccessToken: fmt.Sprintf("token-%d", n),
|
||||
Expiry: time.Now().Add(5 * time.Second), // within default 10s leeway
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src) // default 10s leeway
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Token expires in 5s, which is within 10s leeway, so next call should refresh.
|
||||
md, err := provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer token-2", md["authorization"])
|
||||
assert.Equal(t, 2, src.calls())
|
||||
}
|
||||
|
||||
func TestGetRequestMetadata_CustomLeewayTriggersRefresh(t *testing.T) {
|
||||
var callNum atomic.Int32
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
n := callNum.Add(1)
|
||||
return &oauth2.Token{
|
||||
AccessToken: fmt.Sprintf("token-%d", n),
|
||||
Expiry: time.Now().Add(25 * time.Second), // within custom 30s leeway
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src, WithLeeway(30*time.Second))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Token expires in 25s, which is within 30s leeway, so next call should refresh.
|
||||
md, err := provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer token-2", md["authorization"])
|
||||
assert.Equal(t, 2, src.calls())
|
||||
}
|
||||
|
||||
func TestGetRequestMetadata_ZeroExpiryNeverRefreshes(t *testing.T) {
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "forever-token"}, nil // zero Expiry
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Call multiple times; should never refresh since expiry is zero.
|
||||
for range 10 {
|
||||
_, err = provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, 1, src.calls(), "zero-expiry token should never be refreshed")
|
||||
}
|
||||
|
||||
// --- benchmarks ---
|
||||
|
||||
func BenchmarkGetRequestMetadata_CachedToken(b *testing.B) {
|
||||
src := &mockTokenSource{
|
||||
tokenFunc: func() (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "bench-token", Expiry: time.Now().Add(time.Hour)}, nil
|
||||
},
|
||||
}
|
||||
provider, err := RefreshableToken(src)
|
||||
require.NoError(b, err)
|
||||
|
||||
// Prime the cache.
|
||||
_, err = provider.GetRequestMetadata(context.Background())
|
||||
require.NoError(b, err)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
_, _ = provider.GetRequestMetadata(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type logEntry struct {
|
||||
err error
|
||||
msg string
|
||||
}
|
||||
|
||||
type captureLogger struct {
|
||||
mu sync.Mutex
|
||||
debugs []string
|
||||
infos []string
|
||||
errors []logEntry
|
||||
}
|
||||
|
||||
func (l *captureLogger) Debug(msg string, _ ...any) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.debugs = append(l.debugs, msg)
|
||||
}
|
||||
|
||||
func (l *captureLogger) Info(msg string, _ ...any) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.infos = append(l.infos, msg)
|
||||
}
|
||||
|
||||
func (l *captureLogger) Error(err error, msg string, _ ...any) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.errors = append(l.errors, logEntry{err: err, msg: msg})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNoAuth_GetRequestMetadata(t *testing.T) {
|
||||
auth := NoAuth()
|
||||
md, err := auth.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, md)
|
||||
}
|
||||
|
||||
func TestNoAuth_RequireTransportSecurity(t *testing.T) {
|
||||
auth := NoAuth()
|
||||
assert.False(t, auth.RequireTransportSecurity())
|
||||
}
|
||||
|
||||
func TestStaticToken_GetRequestMetadata(t *testing.T) {
|
||||
auth := StaticToken("my-secret-token")
|
||||
md, err := auth.GetRequestMetadata(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Bearer my-secret-token", md["authorization"])
|
||||
}
|
||||
|
||||
func TestStaticToken_RequireTransportSecurity(t *testing.T) {
|
||||
auth := StaticToken("token")
|
||||
assert.True(t, auth.RequireTransportSecurity())
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
|
||||
internalgrpc "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/grpc"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Config holds all settings needed to create a Client.
|
||||
type Config = types.Config
|
||||
|
||||
// ClientInterface defines the top-level SDK surface.
|
||||
type ClientInterface interface {
|
||||
Sandboxes() SandboxInterface
|
||||
Providers() ProviderInterface
|
||||
Services() ServiceInterface
|
||||
Exec() ExecInterface
|
||||
Files() FileInterface
|
||||
Health() HealthInterface
|
||||
SSH() SSHInterface
|
||||
TCP() TCPInterface
|
||||
Config() ConfigInterface
|
||||
Policy() PolicyInterface
|
||||
Close() error
|
||||
}
|
||||
|
||||
// SandboxInterface is defined in sandbox.go
|
||||
|
||||
// ProviderInterface is defined in provider.go
|
||||
|
||||
// ExecInterface is defined in exec.go
|
||||
|
||||
// FileInterface is defined in file.go
|
||||
|
||||
// Client implements ClientInterface. It holds a gRPC connection and provides
|
||||
// sub-client accessors following the Kubernetes client-go pattern.
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
config Config
|
||||
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
|
||||
sandboxes SandboxInterface
|
||||
providers ProviderInterface
|
||||
services ServiceInterface
|
||||
exec ExecInterface
|
||||
files FileInterface
|
||||
health HealthInterface
|
||||
ssh SSHInterface
|
||||
tcp TCPInterface
|
||||
cfg ConfigInterface
|
||||
policy PolicyInterface
|
||||
}
|
||||
|
||||
// NewClient creates a new SDK client connected to the given gateway.
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if cfg.Address == "" {
|
||||
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "address must not be empty"}
|
||||
}
|
||||
|
||||
if cfg.Auth == nil {
|
||||
cfg.Auth = NoAuth()
|
||||
}
|
||||
|
||||
var tlsParams *internalgrpc.TLSParams
|
||||
if cfg.TLS != nil {
|
||||
tlsParams = &internalgrpc.TLSParams{
|
||||
CertFile: cfg.TLS.CertFile,
|
||||
KeyFile: cfg.TLS.KeyFile,
|
||||
CAFile: cfg.TLS.CAFile,
|
||||
Insecure: cfg.TLS.Insecure,
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := internalgrpc.NewConnection(cfg.Address, tlsParams, cfg.Auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
config: cfg,
|
||||
}
|
||||
|
||||
c.sandboxes = newSandboxClient(conn)
|
||||
c.providers = &stubProviders{}
|
||||
c.services = &stubServices{}
|
||||
c.exec = &stubExec{}
|
||||
c.files = &stubFiles{}
|
||||
c.health = &stubHealth{}
|
||||
c.ssh = &stubSSH{}
|
||||
c.tcp = &stubTCP{}
|
||||
c.cfg = &stubConfig{}
|
||||
c.policy = &stubPolicy{}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Sandboxes returns the sandbox sub-client.
|
||||
func (c *Client) Sandboxes() SandboxInterface { return c.sandboxes }
|
||||
|
||||
// Providers returns the provider sub-client.
|
||||
func (c *Client) Providers() ProviderInterface { return c.providers }
|
||||
|
||||
// Services returns the service sub-client.
|
||||
func (c *Client) Services() ServiceInterface { return c.services }
|
||||
|
||||
// Exec returns the exec sub-client.
|
||||
func (c *Client) Exec() ExecInterface { return c.exec }
|
||||
|
||||
// Files returns the file sub-client.
|
||||
func (c *Client) Files() FileInterface { return c.files }
|
||||
|
||||
// Health returns the health sub-client.
|
||||
func (c *Client) Health() HealthInterface { return c.health }
|
||||
|
||||
// SSH returns the SSH session sub-client.
|
||||
func (c *Client) SSH() SSHInterface { return c.ssh }
|
||||
|
||||
// TCP returns the TCP port forwarding sub-client.
|
||||
func (c *Client) TCP() TCPInterface { return c.tcp }
|
||||
|
||||
// Config returns the configuration sub-client.
|
||||
func (c *Client) Config() ConfigInterface { return c.cfg }
|
||||
|
||||
// Policy returns the policy management sub-client.
|
||||
func (c *Client) Policy() PolicyInterface { return c.policy }
|
||||
|
||||
// Close closes the underlying gRPC connection. Safe to call multiple times.
|
||||
func (c *Client) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
c.closeErr = c.conn.Close()
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewClient_EmptyAddress(t *testing.T) {
|
||||
_, err := NewClient(Config{Address: ""})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "address")
|
||||
}
|
||||
|
||||
func TestNewClient_ValidConfig(t *testing.T) {
|
||||
client, err := NewClient(Config{
|
||||
Address: "localhost:50051",
|
||||
Auth: NoAuth(),
|
||||
TLS: &TLSConfig{Insecure: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client)
|
||||
|
||||
assert.NotNil(t, client.Sandboxes())
|
||||
assert.NotNil(t, client.Providers())
|
||||
assert.NotNil(t, client.Exec())
|
||||
assert.NotNil(t, client.Files())
|
||||
assert.NotNil(t, client.Health())
|
||||
|
||||
err = client.Close()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_CloseIdempotent(t *testing.T) {
|
||||
client, err := NewClient(Config{
|
||||
Address: "localhost:50051",
|
||||
Auth: NoAuth(),
|
||||
TLS: &TLSConfig{Insecure: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Close()
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = client.Close()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNewClient_DefaultAuth(t *testing.T) {
|
||||
client, err := NewClient(Config{
|
||||
Address: "localhost:50051",
|
||||
TLS: &TLSConfig{Insecure: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client)
|
||||
_ = client.Close()
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// SandboxConfig represents the full configuration state of a sandbox.
|
||||
type SandboxConfig = types.SandboxConfig
|
||||
|
||||
// GatewayConfig represents gateway-global settings.
|
||||
type GatewayConfig = types.GatewayConfig
|
||||
|
||||
// ConfigUpdate represents a configuration mutation request.
|
||||
type ConfigUpdate = types.ConfigUpdate
|
||||
|
||||
// ConfigUpdateResult holds the result of a configuration update operation.
|
||||
type ConfigUpdateResult = types.ConfigUpdateResult
|
||||
|
||||
// SettingValue is a typed setting value (string, bool, int64, or bytes).
|
||||
type SettingValue = types.SettingValue
|
||||
|
||||
// SettingValueType identifies which typed field of a SettingValue is active.
|
||||
type SettingValueType = types.SettingValueType
|
||||
|
||||
// EffectiveSetting is a setting value paired with its resolved scope.
|
||||
type EffectiveSetting = types.EffectiveSetting
|
||||
|
||||
// SettingScope indicates whether a setting is sandbox or global.
|
||||
type SettingScope = types.SettingScope
|
||||
|
||||
// PolicySource indicates the source of a policy payload.
|
||||
type PolicySource = types.PolicySource
|
||||
|
||||
// SettingValueType constants re-exported from types package.
|
||||
const (
|
||||
SettingValueString = types.SettingValueString
|
||||
SettingValueBool = types.SettingValueBool
|
||||
SettingValueInt = types.SettingValueInt
|
||||
SettingValueBytes = types.SettingValueBytes
|
||||
)
|
||||
|
||||
// SettingScope constants re-exported from types package.
|
||||
const (
|
||||
SettingScopeUnspecified = types.SettingScopeUnspecified
|
||||
SettingScopeSandbox = types.SettingScopeSandbox
|
||||
SettingScopeGlobal = types.SettingScopeGlobal
|
||||
)
|
||||
|
||||
// PolicySource constants re-exported from types package.
|
||||
const (
|
||||
PolicySourceUnspecified = types.PolicySourceUnspecified
|
||||
PolicySourceSandbox = types.PolicySourceSandbox
|
||||
PolicySourceGlobal = types.PolicySourceGlobal
|
||||
)
|
||||
|
||||
// ConfigInterface defines operations for reading and updating gateway and
|
||||
// sandbox configuration.
|
||||
type ConfigInterface interface {
|
||||
// GetSandbox retrieves the full configuration state for a sandbox,
|
||||
// including policy, effective settings, and revision metadata.
|
||||
// The sandbox is identified by name; the SDK resolves it to an ID internally.
|
||||
GetSandbox(ctx context.Context, workspace, sandboxName string) (*SandboxConfig, error)
|
||||
|
||||
// GetGateway retrieves gateway-global settings.
|
||||
GetGateway(ctx context.Context) (*GatewayConfig, error)
|
||||
|
||||
// Update applies a configuration mutation. For sandbox-scoped updates,
|
||||
// set ConfigUpdate.Name to the sandbox name. For global-scoped updates,
|
||||
// set ConfigUpdate.Global to true.
|
||||
Update(ctx context.Context, workspace string, update *ConfigUpdate) (*ConfigUpdateResult, error)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package v1 provides a Go SDK for interacting with OpenShell servers.
|
||||
//
|
||||
// The SDK follows the Kubernetes client-go sub-client pattern: a single Client
|
||||
// provides typed accessors for each resource domain (Sandboxes, Providers, Exec,
|
||||
// Files, Health, Services, SSH, TCP, Config). All operations accept a context.Context and return idiomatic
|
||||
// Go types. Proto-generated types never appear in the public API.
|
||||
//
|
||||
// # Quick Start
|
||||
//
|
||||
// client, err := v1.NewClient(v1.Config{
|
||||
// Address: "gateway.example.com:443",
|
||||
// Auth: v1.StaticToken("my-token"),
|
||||
// })
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer client.Close()
|
||||
//
|
||||
// # Sandbox Lifecycle
|
||||
//
|
||||
// sandbox, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{
|
||||
// Template: &v1.SandboxTemplate{Image: "python:3.12"},
|
||||
// Environment: map[string]string{"LANG": "en_US.UTF-8"},
|
||||
// }, nil)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// sandbox, err = client.Sandboxes().WaitReady(ctx, "default", sandbox.Name)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// # Command Execution (available in a future release)
|
||||
//
|
||||
// result, err := client.Exec().Run(ctx, "default", sandbox.Name, []string{"echo", "hello"}, v1.ExecOptions{})
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Println(string(result.Stdout)) // "hello\n"
|
||||
//
|
||||
// # Error Handling
|
||||
//
|
||||
// _, err = client.Sandboxes().Get(ctx, "default", "missing")
|
||||
// if v1.IsNotFound(err) {
|
||||
// // handle not found
|
||||
// }
|
||||
//
|
||||
// # Watching
|
||||
//
|
||||
// watcher, err := client.Sandboxes().Watch(ctx, "default", sandbox.Name)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer watcher.Stop()
|
||||
// for event := range watcher.ResultChan() {
|
||||
// fmt.Printf("%s: %s\n", event.Type, event.Object.Name)
|
||||
// }
|
||||
//
|
||||
// # Watching with StopOnTerminal
|
||||
//
|
||||
// Use StopOnTerminal to auto-close the watcher when the sandbox reaches a
|
||||
// terminal phase (Ready or Error):
|
||||
//
|
||||
// watcher, err := client.Sandboxes().Watch(ctx, "default", sandbox.Name,
|
||||
// v1.WatchOptions{StopOnTerminal: true},
|
||||
// )
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// for event := range watcher.ResultChan() {
|
||||
// fmt.Printf("phase: %s\n", event.Object.Status.Phase)
|
||||
// }
|
||||
// // channel closes automatically after Ready or Error
|
||||
//
|
||||
// # Service Exposure (available in a future release)
|
||||
//
|
||||
// Expose an HTTP service running inside a sandbox and retrieve its public URL:
|
||||
//
|
||||
// endpoint, err := client.Services().Expose(ctx, "default", "my-sandbox", "api", 8080, true)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("Service URL: %s\n", endpoint.URL)
|
||||
//
|
||||
// endpoints, err := client.Services().List(ctx, "default", "my-sandbox")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// for _, ep := range endpoints {
|
||||
// fmt.Printf(" %s → port %d (URL: %s)\n", ep.ServiceName, ep.TargetPort, ep.URL)
|
||||
// }
|
||||
//
|
||||
// # Provider Profiles (available in a future release)
|
||||
//
|
||||
// List available provider profiles and import new ones:
|
||||
//
|
||||
// profiles, err := client.Providers().Profiles().List(ctx, "default")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// for _, p := range profiles {
|
||||
// fmt.Printf("%s (%s): %s\n", p.DisplayName, p.Category, p.Description)
|
||||
// }
|
||||
//
|
||||
// result, err := client.Providers().Profiles().Import(ctx, "default", []v1.ProfileImportItem{
|
||||
// {Source: "openai-profile.yaml", Profile: v1.ProviderProfile{
|
||||
// DisplayName: "OpenAI",
|
||||
// Category: v1.ProfileCategoryInference,
|
||||
// }},
|
||||
// })
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// for _, d := range result.Diagnostics {
|
||||
// fmt.Printf("[%s] %s: %s\n", d.Severity, d.Field, d.Message)
|
||||
// }
|
||||
//
|
||||
// # Credential Refresh (available in a future release)
|
||||
//
|
||||
// Configure gateway-owned credential refresh for a provider:
|
||||
//
|
||||
// status, err := client.Providers().Refresh().Configure(ctx, "default", &v1.RefreshConfig{
|
||||
// Provider: "openai",
|
||||
// CredentialKey: "api-key",
|
||||
// Strategy: v1.RefreshStrategyOAuth2ClientCredentials,
|
||||
// Material: map[string]string{"client_id": "xxx", "client_secret": "yyy"},
|
||||
// })
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("Refresh status: %s (next: %s)\n", status.Status, status.NextRefreshAt)
|
||||
//
|
||||
// # Token Refresh
|
||||
//
|
||||
// Use RefreshableToken for automatic OAuth2 token caching and refresh.
|
||||
// Concurrent callers share a single refresh call:
|
||||
//
|
||||
// tokenSource := oauth2Config.TokenSource(ctx, initialToken)
|
||||
// auth, err := v1.RefreshableToken(tokenSource,
|
||||
// v1.WithLeeway(30*time.Second),
|
||||
// )
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// client, err := v1.NewClient(v1.Config{
|
||||
// Address: "gateway.example.com:443",
|
||||
// Auth: auth,
|
||||
// })
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer client.Close()
|
||||
//
|
||||
// # Extra Headers
|
||||
//
|
||||
// Use WithExtraHeaders to attach additional per-RPC headers to any auth
|
||||
// provider. This is useful for edge proxies, API gateways, or any middleware
|
||||
// that requires custom headers alongside standard authentication:
|
||||
//
|
||||
// base := v1.StaticToken("my-token")
|
||||
// auth, err := v1.WithExtraHeaders(base, map[string]string{
|
||||
// "x-proxy-key": "proxy-secret",
|
||||
// "x-tenant-id": "acme-corp",
|
||||
// })
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// client, err := v1.NewClient(v1.Config{
|
||||
// Address: "gateway.example.com:443",
|
||||
// Auth: auth,
|
||||
// })
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer client.Close()
|
||||
//
|
||||
// Keys are normalized to lowercase (per HTTP/2 RFC 9113). On key collision,
|
||||
// extra headers take precedence over base auth headers. Empty-string values
|
||||
// are silently dropped. WithExtraHeaders composes with any AuthProvider,
|
||||
// including RefreshableToken:
|
||||
//
|
||||
// tokenSource := oauth2Config.TokenSource(ctx, initialToken)
|
||||
// refreshAuth, err := v1.RefreshableToken(tokenSource)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// auth, err := v1.WithExtraHeaders(refreshAuth, map[string]string{
|
||||
// "x-proxy-key": "proxy-secret",
|
||||
// })
|
||||
//
|
||||
// # SSH Session Management (available in a future release)
|
||||
//
|
||||
// Create an SSH session for a sandbox and use the returned connection details.
|
||||
// Note: CreateSession accepts a sandbox ID, not a name. For name-based access
|
||||
// with automatic session cleanup, prefer SSH().Tunnel() instead.
|
||||
//
|
||||
// session, err := client.SSH().CreateSession(ctx, "default", sandbox.ID)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("SSH to %s:%d (scheme: %s)\n",
|
||||
// session.GatewayHost, session.GatewayPort, session.GatewayScheme)
|
||||
// fmt.Printf("Host key: %s\n", session.HostKeyFingerprint)
|
||||
// // Use session.Token to authenticate the SSH connection.
|
||||
//
|
||||
// revoked, err := client.SSH().RevokeSession(ctx, "default", session.Token)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("Session revoked: %v\n", revoked)
|
||||
//
|
||||
// # TCP Port Forwarding (available in a future release)
|
||||
//
|
||||
// Forward a local connection to a port inside a sandbox:
|
||||
//
|
||||
// conn, err := client.TCP().Forward(ctx, "default", "my-sandbox", 5432)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer conn.Close()
|
||||
//
|
||||
// // conn implements io.ReadWriteCloser, use it like a net.Conn.
|
||||
// _, err = conn.Write([]byte("PING\n"))
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// buf := make([]byte, 1024)
|
||||
// n, err := conn.Read(buf)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("Response: %s\n", buf[:n])
|
||||
//
|
||||
// Use WithForwardServiceID to tag the forwarding session with a service
|
||||
// identifier for audit logging:
|
||||
//
|
||||
// conn, err := client.TCP().Forward(ctx, "default", "my-sandbox", 5432,
|
||||
// v1.WithForwardServiceID("billing-db"),
|
||||
// )
|
||||
//
|
||||
// # SSH Tunneling (available in a future release)
|
||||
//
|
||||
// Create an SSH tunnel to a sandbox port in a single call. Tunnel combines
|
||||
// session creation, TCP forwarding with an SSH relay target, and automatic
|
||||
// session cleanup into one operation:
|
||||
//
|
||||
// tunnel, err := client.SSH().Tunnel(ctx, "default", "my-sandbox", 22)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// defer tunnel.Close()
|
||||
//
|
||||
// // tunnel implements io.ReadWriteCloser. The underlying SSH session
|
||||
// // is automatically revoked when Close is called.
|
||||
// _, err = tunnel.Write([]byte("SSH-2.0-client\r\n"))
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// buf := make([]byte, 256)
|
||||
// n, err := tunnel.Read(buf)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("Server banner: %s\n", buf[:n])
|
||||
//
|
||||
// Use WithTunnelServiceID to associate a service identifier with the tunnel:
|
||||
//
|
||||
// tunnel, err := client.SSH().Tunnel(ctx, "default", "my-sandbox", 22,
|
||||
// v1.WithTunnelServiceID("dev-ssh"),
|
||||
// )
|
||||
//
|
||||
// # Sandbox Policy
|
||||
//
|
||||
// Set an initial security policy when creating a sandbox:
|
||||
//
|
||||
// sandbox, err := client.Sandboxes().Create(ctx, "default", "secure-sandbox", &v1.SandboxSpec{
|
||||
// Template: &v1.SandboxTemplate{Image: "python:3.12"},
|
||||
// Policy: &v1.SandboxPolicy{
|
||||
// Version: 1,
|
||||
// Filesystem: &v1.FilesystemPolicy{
|
||||
// IncludeWorkdir: true,
|
||||
// ReadOnly: []string{"/usr", "/lib"},
|
||||
// },
|
||||
// Process: &v1.ProcessPolicy{
|
||||
// RunAsUser: "sandbox",
|
||||
// RunAsGroup: "sandbox",
|
||||
// },
|
||||
// NetworkPolicies: map[string]v1.NetworkPolicyRule{
|
||||
// "allow-api": {
|
||||
// Name: "allow-api",
|
||||
// Endpoints: []v1.PolicyNetworkEndpoint{
|
||||
// {Host: "api.example.com", Port: 443, Protocol: "tcp"},
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// }, nil)
|
||||
//
|
||||
// Replace the full policy at runtime via configuration update (available in a future release):
|
||||
//
|
||||
// result, err := client.Config().Update(ctx, "default", &v1.ConfigUpdate{
|
||||
// Name: "secure-sandbox",
|
||||
// Policy: &v1.SandboxPolicy{
|
||||
// Version: 2,
|
||||
// NetworkPolicies: map[string]v1.NetworkPolicyRule{
|
||||
// "allow-all": {Name: "allow-all"},
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
//
|
||||
// Read a policy back from revision history (available in a future release):
|
||||
//
|
||||
// revisions, err := client.Policy().List(ctx, "default")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// for _, rev := range revisions {
|
||||
// if rev.Policy != nil {
|
||||
// fmt.Printf("v%d: %d network rules\n", rev.Version, len(rev.Policy.NetworkPolicies))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # Configuration Management (available in a future release)
|
||||
//
|
||||
// Read sandbox and gateway configuration, and update settings:
|
||||
//
|
||||
// sbCfg, err := client.Config().GetSandbox(ctx, "default", "my-sandbox")
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("Config revision: %d\n", sbCfg.ConfigRevision)
|
||||
// for name, setting := range sbCfg.Settings {
|
||||
// fmt.Printf(" %s = %v (scope: %s)\n", name, setting.Value, setting.Scope)
|
||||
// }
|
||||
//
|
||||
// gwCfg, err := client.Config().GetGateway(ctx)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("Gateway settings revision: %d\n", gwCfg.SettingsRevision)
|
||||
//
|
||||
// result, err := client.Config().Update(ctx, "default", &v1.ConfigUpdate{
|
||||
// Name: "my-sandbox",
|
||||
// SettingKey: "max_tokens",
|
||||
// SettingValue: &v1.SettingValue{
|
||||
// Type: v1.SettingValueInt,
|
||||
// IntVal: 8192,
|
||||
// },
|
||||
// })
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("New settings revision: %d\n", result.SettingsRevision)
|
||||
package v1
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// ErrorCode classifies SDK errors by their gRPC origin.
|
||||
type ErrorCode = types.ErrorCode
|
||||
|
||||
// ErrorCode values for classifying gRPC errors.
|
||||
const (
|
||||
ErrorNotFound = types.ErrorNotFound
|
||||
ErrorAlreadyExists = types.ErrorAlreadyExists
|
||||
ErrorUnavailable = types.ErrorUnavailable
|
||||
ErrorPermissionDenied = types.ErrorPermissionDenied
|
||||
ErrorInvalidArgument = types.ErrorInvalidArgument
|
||||
ErrorDeadlineExceeded = types.ErrorDeadlineExceeded
|
||||
ErrorCancelled = types.ErrorCancelled
|
||||
ErrorInternal = types.ErrorInternal
|
||||
ErrorUnimplemented = types.ErrorUnimplemented
|
||||
ErrorConflict = types.ErrorConflict
|
||||
ErrorUnauthenticated = types.ErrorUnauthenticated
|
||||
)
|
||||
|
||||
// StatusError is the typed error returned by all SDK operations.
|
||||
type StatusError = types.StatusError
|
||||
|
||||
// IsNotFound returns true if the error indicates a resource was not found.
|
||||
func IsNotFound(err error) bool { return types.IsNotFound(err) }
|
||||
|
||||
// IsAlreadyExists returns true if the error indicates a resource already exists.
|
||||
func IsAlreadyExists(err error) bool { return types.IsAlreadyExists(err) }
|
||||
|
||||
// IsUnavailable returns true if the error indicates the service is unavailable.
|
||||
func IsUnavailable(err error) bool { return types.IsUnavailable(err) }
|
||||
|
||||
// IsPermissionDenied returns true if the error indicates insufficient permissions.
|
||||
func IsPermissionDenied(err error) bool { return types.IsPermissionDenied(err) }
|
||||
|
||||
// IsInvalidArgument returns true if the error indicates an invalid argument.
|
||||
func IsInvalidArgument(err error) bool { return types.IsInvalidArgument(err) }
|
||||
|
||||
// IsDeadlineExceeded returns true if the error indicates a deadline was exceeded.
|
||||
func IsDeadlineExceeded(err error) bool { return types.IsDeadlineExceeded(err) }
|
||||
|
||||
// IsCancelled returns true if the error indicates the operation was cancelled.
|
||||
func IsCancelled(err error) bool { return types.IsCancelled(err) }
|
||||
|
||||
// IsUnimplemented returns true if the error indicates the operation is not implemented.
|
||||
func IsUnimplemented(err error) bool { return types.IsUnimplemented(err) }
|
||||
|
||||
// IsConflict returns true if the error indicates a conflict, such as
|
||||
// optimistic concurrency or an invalid state transition.
|
||||
func IsConflict(err error) bool { return types.IsConflict(err) }
|
||||
|
||||
// IsUnauthenticated returns true if the error indicates missing or invalid credentials.
|
||||
func IsUnauthenticated(err error) bool { return types.IsUnauthenticated(err) }
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStatusError_Error(t *testing.T) {
|
||||
err := &StatusError{
|
||||
Code: ErrorNotFound,
|
||||
Message: "sandbox not found",
|
||||
}
|
||||
s := err.Error()
|
||||
assert.Contains(t, s, "NotFound")
|
||||
assert.Contains(t, s, "sandbox not found")
|
||||
}
|
||||
|
||||
func TestStatusError_ErrorWithCause(t *testing.T) {
|
||||
cause := fmt.Errorf("underlying issue")
|
||||
err := &StatusError{
|
||||
Code: ErrorInvalidArgument,
|
||||
Message: "bad name",
|
||||
Cause: cause,
|
||||
}
|
||||
s := err.Error()
|
||||
assert.Contains(t, s, "InvalidArgument")
|
||||
assert.Contains(t, s, "bad name")
|
||||
assert.ErrorIs(t, err, cause)
|
||||
}
|
||||
|
||||
func TestIsNotFound(t *testing.T) {
|
||||
err := &StatusError{Code: ErrorNotFound, Message: "not found"}
|
||||
assert.True(t, IsNotFound(err))
|
||||
assert.False(t, IsAlreadyExists(err))
|
||||
}
|
||||
|
||||
func TestIsAlreadyExists(t *testing.T) {
|
||||
err := &StatusError{Code: ErrorAlreadyExists, Message: "exists"}
|
||||
assert.True(t, IsAlreadyExists(err))
|
||||
assert.False(t, IsNotFound(err))
|
||||
}
|
||||
|
||||
func TestIsUnavailable(t *testing.T) {
|
||||
err := &StatusError{Code: ErrorUnavailable, Message: "down"}
|
||||
assert.True(t, IsUnavailable(err))
|
||||
}
|
||||
|
||||
func TestIsPermissionDenied(t *testing.T) {
|
||||
err := &StatusError{Code: ErrorPermissionDenied, Message: "denied"}
|
||||
assert.True(t, IsPermissionDenied(err))
|
||||
}
|
||||
|
||||
func TestIsInvalidArgument(t *testing.T) {
|
||||
err := &StatusError{Code: ErrorInvalidArgument, Message: "invalid"}
|
||||
assert.True(t, IsInvalidArgument(err))
|
||||
}
|
||||
|
||||
func TestIsDeadlineExceeded(t *testing.T) {
|
||||
err := &StatusError{Code: ErrorDeadlineExceeded, Message: "timeout"}
|
||||
assert.True(t, IsDeadlineExceeded(err))
|
||||
}
|
||||
|
||||
func TestIsCancelled(t *testing.T) {
|
||||
err := &StatusError{Code: ErrorCancelled, Message: "cancelled"}
|
||||
assert.True(t, IsCancelled(err))
|
||||
}
|
||||
|
||||
func TestIsConflict(t *testing.T) {
|
||||
err := &StatusError{Code: ErrorConflict, Message: "version conflict"}
|
||||
assert.True(t, IsConflict(err))
|
||||
assert.False(t, IsNotFound(err))
|
||||
}
|
||||
|
||||
func TestIsHelpers_NonStatusError(t *testing.T) {
|
||||
err := errors.New("plain error")
|
||||
assert.False(t, IsNotFound(err))
|
||||
assert.False(t, IsAlreadyExists(err))
|
||||
assert.False(t, IsUnavailable(err))
|
||||
assert.False(t, IsPermissionDenied(err))
|
||||
assert.False(t, IsInvalidArgument(err))
|
||||
assert.False(t, IsDeadlineExceeded(err))
|
||||
assert.False(t, IsCancelled(err))
|
||||
assert.False(t, IsConflict(err))
|
||||
}
|
||||
|
||||
func TestIsHelpers_NilError(t *testing.T) {
|
||||
assert.False(t, IsNotFound(nil))
|
||||
assert.False(t, IsConflict(nil))
|
||||
}
|
||||
|
||||
func TestStatusError_WrappedError(t *testing.T) {
|
||||
inner := &StatusError{Code: ErrorNotFound, Message: "not found"}
|
||||
wrapped := fmt.Errorf("operation failed: %w", inner)
|
||||
assert.True(t, IsNotFound(wrapped))
|
||||
|
||||
var se *StatusError
|
||||
require.True(t, errors.As(wrapped, &se))
|
||||
assert.Equal(t, ErrorNotFound, se.Code)
|
||||
}
|
||||
|
||||
func TestErrorCode_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
code ErrorCode
|
||||
want string
|
||||
}{
|
||||
{ErrorNotFound, "NotFound"},
|
||||
{ErrorAlreadyExists, "AlreadyExists"},
|
||||
{ErrorUnavailable, "Unavailable"},
|
||||
{ErrorPermissionDenied, "PermissionDenied"},
|
||||
{ErrorInvalidArgument, "InvalidArgument"},
|
||||
{ErrorDeadlineExceeded, "DeadlineExceeded"},
|
||||
{ErrorCancelled, "Cancelled"},
|
||||
{ErrorInternal, "Internal"},
|
||||
{ErrorUnimplemented, "Unimplemented"},
|
||||
{ErrorConflict, "Conflict"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, tt.code.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// ExecResult holds the collected output of a completed command execution.
|
||||
type ExecResult = types.ExecResult
|
||||
|
||||
// ExecChunk represents a single chunk of output from a streaming command execution.
|
||||
type ExecChunk = types.ExecChunk
|
||||
|
||||
// ExecStream provides an iterator interface over streaming command output.
|
||||
type ExecStream interface {
|
||||
Next() (*ExecChunk, error)
|
||||
ExitCode() (int, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// InteractiveSession provides bidirectional I/O for interactive command execution.
|
||||
type InteractiveSession interface {
|
||||
Read(p []byte) (int, error)
|
||||
Write(p []byte) (int, error)
|
||||
Resize(cols, rows uint32) error
|
||||
ExitCode() (int, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ExecInterface defines command execution operations on sandboxes.
|
||||
// Methods accept a sandbox name and resolve it to an ID internally.
|
||||
type ExecInterface interface {
|
||||
Run(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (*ExecResult, error)
|
||||
Stream(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (ExecStream, error)
|
||||
Interactive(ctx context.Context, workspace, sandboxName string, command []string, cols, rows uint32, opts ...ExecOptions) (InteractiveSession, error)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import "context"
|
||||
|
||||
// FileInterface defines file transfer operations on sandboxes.
|
||||
// Methods accept a sandbox name and resolve it to an ID internally.
|
||||
type FileInterface interface {
|
||||
Upload(ctx context.Context, workspace, sandboxName string, localPath string, remotePath string) error
|
||||
Download(ctx context.Context, workspace, sandboxName string, remotePath string, localPath string) error
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package v1 provides the OpenShell SDK client.
|
||||
// gRPC error conversion is handled by the internal/converter package.
|
||||
package v1
|
||||
|
||||
import "context"
|
||||
|
||||
func contextError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch err {
|
||||
case context.DeadlineExceeded:
|
||||
return &StatusError{Code: ErrorDeadlineExceeded, Message: err.Error(), Cause: err}
|
||||
case context.Canceled:
|
||||
return &StatusError{Code: ErrorCancelled, Message: err.Error(), Cause: err}
|
||||
default:
|
||||
return &StatusError{Code: ErrorInternal, Message: err.Error(), Cause: err}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// HealthResult holds the result of a health check.
|
||||
type HealthResult = types.HealthResult
|
||||
|
||||
// HealthInterface defines health check operations.
|
||||
type HealthInterface interface {
|
||||
Check(ctx context.Context) (*HealthResult, error)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build integration
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func gatewayAddress(t *testing.T) string {
|
||||
t.Helper()
|
||||
addr := os.Getenv("OPENSHELL_GATEWAY_ADDRESS")
|
||||
if addr == "" {
|
||||
t.Skip("OPENSHELL_GATEWAY_ADDRESS not set")
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func TestIntegration_HealthCheck(t *testing.T) {
|
||||
addr := gatewayAddress(t)
|
||||
|
||||
client, err := NewClient(Config{Address: addr})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
t.Skip("TODO: Health().Check() is a stub until PR B lands")
|
||||
|
||||
_, err = client.Health().Check(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestIntegration_ProviderLifecycle(t *testing.T) {
|
||||
addr := gatewayAddress(t)
|
||||
|
||||
client, err := NewClient(Config{Address: addr})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
t.Skip("TODO: implement provider create/get/list/delete integration test")
|
||||
}
|
||||
|
||||
func TestIntegration_SandboxLifecycle(t *testing.T) {
|
||||
addr := gatewayAddress(t)
|
||||
|
||||
client, err := NewClient(Config{Address: addr})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
t.Skip("TODO: implement sandbox create/wait-ready/delete integration test")
|
||||
}
|
||||
|
||||
func TestIntegration_ExecRun(t *testing.T) {
|
||||
addr := gatewayAddress(t)
|
||||
|
||||
client, err := NewClient(Config{Address: addr})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
t.Skip("TODO: implement exec run integration test")
|
||||
}
|
||||
|
||||
func TestIntegration_FileTransfer(t *testing.T) {
|
||||
addr := gatewayAddress(t)
|
||||
|
||||
client, err := NewClient(Config{Address: addr})
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
t.Skip("TODO: implement file upload/download integration test")
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import "google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
// CopyStringMap returns a shallow copy of a string-to-string map.
|
||||
// Returns nil for nil input.
|
||||
func CopyStringMap(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
c := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
c[k] = v
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// CopyBoolPtr returns a copy of a *bool pointer.
|
||||
// Returns nil for nil input.
|
||||
func CopyBoolPtr(p *bool) *bool {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := *p
|
||||
return &v
|
||||
}
|
||||
|
||||
// CopyStringSlice returns a copy of a string slice.
|
||||
// Returns nil for nil input.
|
||||
func CopyStringSlice(s []string) []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
c := make([]string, len(s))
|
||||
copy(c, s)
|
||||
return c
|
||||
}
|
||||
|
||||
// CopyByteSlice returns a copy of a byte slice.
|
||||
// Returns nil for nil input.
|
||||
func CopyByteSlice(b []byte) []byte {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
c := make([]byte, len(b))
|
||||
copy(c, b)
|
||||
return c
|
||||
}
|
||||
|
||||
func structToMap(s *structpb.Struct) map[string]any {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.AsMap()
|
||||
}
|
||||
|
||||
func mapToStruct(m map[string]any) (*structpb.Struct, error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return structpb.NewStruct(m)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
|
||||
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
|
||||
sandboxpb "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
)
|
||||
|
||||
// These tests use protobuf reflection to detect proto fields that the
|
||||
// converter layer does not handle. When buf generates new fields from an
|
||||
// updated .proto, the field name appears in the proto descriptor but not in
|
||||
// the "handled" set below.
|
||||
//
|
||||
// Unhandled fields FAIL the test so that proto drift is caught immediately.
|
||||
// If a field is intentionally deferred, add it to the "skipped" set with a
|
||||
// justification comment.
|
||||
|
||||
func TestConverterCoversAllProtoFields_SandboxSpec(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"log_level": true,
|
||||
"environment": true,
|
||||
"template": true,
|
||||
"policy": true,
|
||||
"providers": true,
|
||||
"resource_requirements": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&pb.SandboxSpec{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_SandboxTemplate(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"image": true,
|
||||
"runtime_class_name": true,
|
||||
"agent_socket": true,
|
||||
"labels": true,
|
||||
"annotations": true,
|
||||
"environment": true,
|
||||
"resources": true,
|
||||
"user_namespaces": true,
|
||||
"driver_config": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&pb.SandboxTemplate{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_SandboxStatus(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"sandbox_name": true,
|
||||
"agent_pod": true,
|
||||
"agent_fd": true,
|
||||
"sandbox_fd": true,
|
||||
"phase": true,
|
||||
"conditions": true,
|
||||
"current_policy_version": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&pb.SandboxStatus{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_SandboxCondition(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"type": true,
|
||||
"status": true,
|
||||
"reason": true,
|
||||
"message": true,
|
||||
"last_transition_time": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&pb.SandboxCondition{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"version": true,
|
||||
"filesystem": true,
|
||||
"network_policies": true,
|
||||
"process": true,
|
||||
"landlock": true,
|
||||
}
|
||||
|
||||
skipped := fieldSet{
|
||||
// Middleware support is not yet exposed in the SDK domain model.
|
||||
// Tracked in GitHub issue #36 for Drop D.
|
||||
"network_middlewares": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&sandboxpb.SandboxPolicy{}).ProtoReflect().Descriptor(), handled, skipped)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_NetworkEndpoint(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"host": true,
|
||||
"port": true,
|
||||
"ports": true,
|
||||
"protocol": true,
|
||||
"tls": true,
|
||||
"enforcement": true,
|
||||
"access": true,
|
||||
"rules": true,
|
||||
"allowed_ips": true,
|
||||
"deny_rules": true,
|
||||
"allow_encoded_slash": true,
|
||||
"persisted_queries": true,
|
||||
"graphql_persisted_queries": true,
|
||||
"graphql_max_body_bytes": true,
|
||||
"path": true,
|
||||
"websocket_credential_rewrite": true,
|
||||
"request_body_credential_rewrite": true,
|
||||
"advisor_proposed": true,
|
||||
"credential_signing": true,
|
||||
"signing_service": true,
|
||||
"signing_region": true,
|
||||
"json_rpc_max_body_bytes": true,
|
||||
"mcp": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&sandboxpb.NetworkEndpoint{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_L7Allow(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"method": true,
|
||||
"path": true,
|
||||
"command": true,
|
||||
"query": true,
|
||||
"operation_type": true,
|
||||
"operation_name": true,
|
||||
"fields": true,
|
||||
"params": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&sandboxpb.L7Allow{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_L7DenyRule(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"method": true,
|
||||
"path": true,
|
||||
"command": true,
|
||||
"query": true,
|
||||
"operation_type": true,
|
||||
"operation_name": true,
|
||||
"fields": true,
|
||||
"params": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&sandboxpb.L7DenyRule{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_Provider(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"metadata": true,
|
||||
"type": true,
|
||||
"credentials": true,
|
||||
"config": true,
|
||||
"credential_expires_at_ms": true,
|
||||
"profile_workspace": true,
|
||||
"credential_handles": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&dm.Provider{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_CredentialHandle(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"driver": true,
|
||||
"handle": true,
|
||||
"metadata": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&dm.CredentialHandle{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
func TestConverterCoversAllProtoFields_McpOptions(t *testing.T) {
|
||||
handled := fieldSet{
|
||||
"strict_tool_names": true,
|
||||
"allow_all_known_mcp_methods": true,
|
||||
}
|
||||
|
||||
assertAllFieldsCovered(t, (&sandboxpb.McpOptions{}).ProtoReflect().Descriptor(), handled, nil)
|
||||
}
|
||||
|
||||
// fieldSet tracks proto field names that the converter handles.
|
||||
type fieldSet map[string]bool
|
||||
|
||||
// assertAllFieldsCovered fails the test for proto fields not present in
|
||||
// either handled or skipped. Stale entries in the handled set (fields
|
||||
// removed from the proto) also fail.
|
||||
func assertAllFieldsCovered(
|
||||
t *testing.T,
|
||||
desc protoreflect.MessageDescriptor,
|
||||
handled fieldSet,
|
||||
skipped fieldSet,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
fields := desc.Fields()
|
||||
for i := 0; i < fields.Len(); i++ {
|
||||
name := string(fields.Get(i).Name())
|
||||
if handled[name] || skipped[name] {
|
||||
continue
|
||||
}
|
||||
t.Errorf(
|
||||
"proto %s field %q is not handled by the converter and not explicitly skipped. "+
|
||||
"Add converter support in the appropriate FromProto/ToProto function, "+
|
||||
"or add it to the skipped set with a justification.",
|
||||
desc.FullName(), name,
|
||||
)
|
||||
}
|
||||
|
||||
for name := range handled {
|
||||
found := false
|
||||
for i := 0; i < fields.Len(); i++ {
|
||||
if string(fields.Get(i).Name()) == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf(
|
||||
"handled field %q is listed for proto %s but does not exist in the descriptor. "+
|
||||
"The proto field may have been removed or renamed.",
|
||||
name, desc.FullName(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package converter maps between gRPC/proto types and SDK domain types.
|
||||
package converter
|
||||
|
||||
import (
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var grpcToSDK = map[codes.Code]types.ErrorCode{
|
||||
codes.NotFound: types.ErrorNotFound,
|
||||
codes.AlreadyExists: types.ErrorAlreadyExists,
|
||||
codes.Unavailable: types.ErrorUnavailable,
|
||||
codes.PermissionDenied: types.ErrorPermissionDenied,
|
||||
codes.Unauthenticated: types.ErrorUnauthenticated,
|
||||
codes.InvalidArgument: types.ErrorInvalidArgument,
|
||||
codes.DeadlineExceeded: types.ErrorDeadlineExceeded,
|
||||
codes.Canceled: types.ErrorCancelled,
|
||||
codes.Internal: types.ErrorInternal,
|
||||
codes.Unimplemented: types.ErrorUnimplemented,
|
||||
codes.Aborted: types.ErrorConflict,
|
||||
codes.FailedPrecondition: types.ErrorConflict,
|
||||
}
|
||||
|
||||
// FromGRPCError converts a gRPC error to a typed StatusError.
|
||||
// Returns nil for nil errors and OK status. Non-gRPC errors pass through unchanged.
|
||||
func FromGRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
if st.Code() == codes.OK {
|
||||
return nil
|
||||
}
|
||||
|
||||
code, mapped := grpcToSDK[st.Code()]
|
||||
if !mapped {
|
||||
code = types.ErrorInternal
|
||||
}
|
||||
|
||||
return &types.StatusError{
|
||||
Code: code,
|
||||
Message: st.Message(),
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestFromGRPCError_NotFound(t *testing.T) {
|
||||
grpcErr := status.Error(codes.NotFound, "sandbox not found")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
assert.True(t, v1.IsNotFound(err))
|
||||
}
|
||||
|
||||
func TestFromGRPCError_AlreadyExists(t *testing.T) {
|
||||
grpcErr := status.Error(codes.AlreadyExists, "already exists")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
assert.True(t, v1.IsAlreadyExists(err))
|
||||
}
|
||||
|
||||
func TestFromGRPCError_Unavailable(t *testing.T) {
|
||||
grpcErr := status.Error(codes.Unavailable, "service down")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
assert.True(t, v1.IsUnavailable(err))
|
||||
}
|
||||
|
||||
func TestFromGRPCError_PermissionDenied(t *testing.T) {
|
||||
grpcErr := status.Error(codes.PermissionDenied, "denied")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
assert.True(t, v1.IsPermissionDenied(err))
|
||||
}
|
||||
|
||||
func TestFromGRPCError_InvalidArgument(t *testing.T) {
|
||||
grpcErr := status.Error(codes.InvalidArgument, "bad arg")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
assert.True(t, v1.IsInvalidArgument(err))
|
||||
}
|
||||
|
||||
func TestFromGRPCError_DeadlineExceeded(t *testing.T) {
|
||||
grpcErr := status.Error(codes.DeadlineExceeded, "timeout")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
assert.True(t, v1.IsDeadlineExceeded(err))
|
||||
}
|
||||
|
||||
func TestFromGRPCError_Cancelled(t *testing.T) {
|
||||
grpcErr := status.Error(codes.Canceled, "cancelled")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
assert.True(t, v1.IsCancelled(err))
|
||||
}
|
||||
|
||||
func TestFromGRPCError_Internal(t *testing.T) {
|
||||
grpcErr := status.Error(codes.Internal, "internal error")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
|
||||
var se *v1.StatusError
|
||||
require.ErrorAs(t, err, &se)
|
||||
assert.Equal(t, v1.ErrorInternal, se.Code)
|
||||
}
|
||||
|
||||
func TestFromGRPCError_Unimplemented(t *testing.T) {
|
||||
grpcErr := status.Error(codes.Unimplemented, "not implemented")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
|
||||
var se *v1.StatusError
|
||||
require.ErrorAs(t, err, &se)
|
||||
assert.Equal(t, v1.ErrorUnimplemented, se.Code)
|
||||
}
|
||||
|
||||
func TestFromGRPCError_Aborted(t *testing.T) {
|
||||
grpcErr := status.Error(codes.Aborted, "version conflict")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
assert.True(t, v1.IsConflict(err))
|
||||
|
||||
var se *v1.StatusError
|
||||
require.ErrorAs(t, err, &se)
|
||||
assert.Equal(t, v1.ErrorConflict, se.Code)
|
||||
assert.Equal(t, "version conflict", se.Message)
|
||||
}
|
||||
|
||||
func TestFromGRPCError_UnmappedCode(t *testing.T) {
|
||||
grpcErr := status.Error(codes.DataLoss, "data loss")
|
||||
err := FromGRPCError(grpcErr)
|
||||
require.Error(t, err)
|
||||
|
||||
var se *v1.StatusError
|
||||
require.ErrorAs(t, err, &se)
|
||||
assert.Equal(t, v1.ErrorInternal, se.Code)
|
||||
}
|
||||
|
||||
func TestFromGRPCError_NilError(t *testing.T) {
|
||||
err := FromGRPCError(nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestFromGRPCError_NonGRPCError(t *testing.T) {
|
||||
err := FromGRPCError(assert.AnError)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, assert.AnError, err)
|
||||
}
|
||||
|
||||
func TestFromGRPCError_OKStatus(t *testing.T) {
|
||||
grpcErr := status.Error(codes.OK, "")
|
||||
err := FromGRPCError(grpcErr)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
|
||||
)
|
||||
|
||||
// --- LogLine ---
|
||||
|
||||
// LogLineFromProto converts a proto SandboxLogLine to an SDK LogLine.
|
||||
func LogLineFromProto(l *pb.SandboxLogLine) *types.LogLine {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.LogLine{
|
||||
Timestamp: TimeFromMillis(l.GetTimestampMs()),
|
||||
Level: l.GetLevel(),
|
||||
Target: l.GetTarget(),
|
||||
Message: l.GetMessage(),
|
||||
Source: l.GetSource(),
|
||||
Fields: CopyStringMap(l.GetFields()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- LogResult ---
|
||||
|
||||
// LogResultFromProto converts a proto GetSandboxLogsResponse to an SDK LogResult.
|
||||
func LogResultFromProto(r *pb.GetSandboxLogsResponse) *types.LogResult {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
result := &types.LogResult{
|
||||
BufferTotal: r.GetBufferTotal(),
|
||||
}
|
||||
if logs := r.GetLogs(); len(logs) > 0 {
|
||||
result.Lines = make([]types.LogLine, 0, len(logs))
|
||||
for _, l := range logs {
|
||||
if converted := LogLineFromProto(l); converted != nil {
|
||||
result.Lines = append(result.Lines, *converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- LogLine ---
|
||||
|
||||
func TestLogLineFromProto(t *testing.T) {
|
||||
proto := &pb.SandboxLogLine{
|
||||
SandboxId: "sbx-1",
|
||||
TimestampMs: 1700000000000,
|
||||
Level: "INFO",
|
||||
Target: "network",
|
||||
Message: "Connection established",
|
||||
Source: "sandbox-agent",
|
||||
Fields: map[string]string{
|
||||
"host": "api.example.com",
|
||||
"port": "443",
|
||||
},
|
||||
}
|
||||
|
||||
line := LogLineFromProto(proto)
|
||||
|
||||
require.NotNil(t, line)
|
||||
assert.False(t, line.Timestamp.IsZero())
|
||||
assert.Equal(t, "INFO", line.Level)
|
||||
assert.Equal(t, "network", line.Target)
|
||||
assert.Equal(t, "Connection established", line.Message)
|
||||
assert.Equal(t, "sandbox-agent", line.Source)
|
||||
assert.Equal(t, "api.example.com", line.Fields["host"])
|
||||
assert.Equal(t, "443", line.Fields["port"])
|
||||
}
|
||||
|
||||
func TestLogLineFromProto_Nil(t *testing.T) {
|
||||
assert.Nil(t, LogLineFromProto(nil))
|
||||
}
|
||||
|
||||
func TestLogLineDeepCopy(t *testing.T) {
|
||||
proto := &pb.SandboxLogLine{
|
||||
TimestampMs: 1700000000000,
|
||||
Level: "WARN",
|
||||
Message: "test",
|
||||
Fields: map[string]string{
|
||||
"key": "value",
|
||||
},
|
||||
}
|
||||
|
||||
line := LogLineFromProto(proto)
|
||||
proto.Fields["key"] = "changed"
|
||||
|
||||
assert.Equal(t, "value", line.Fields["key"])
|
||||
}
|
||||
|
||||
// --- LogResult ---
|
||||
|
||||
func TestLogResultFromProto(t *testing.T) {
|
||||
proto := &pb.GetSandboxLogsResponse{
|
||||
Logs: []*pb.SandboxLogLine{
|
||||
{TimestampMs: 1700000000000, Level: "INFO", Message: "first"},
|
||||
{TimestampMs: 1700000001000, Level: "DEBUG", Message: "second"},
|
||||
},
|
||||
BufferTotal: 100,
|
||||
}
|
||||
|
||||
result := LogResultFromProto(proto)
|
||||
|
||||
require.NotNil(t, result)
|
||||
assert.Len(t, result.Lines, 2)
|
||||
assert.Equal(t, "INFO", result.Lines[0].Level)
|
||||
assert.Equal(t, "first", result.Lines[0].Message)
|
||||
assert.Equal(t, "DEBUG", result.Lines[1].Level)
|
||||
assert.Equal(t, "second", result.Lines[1].Message)
|
||||
assert.Equal(t, uint32(100), result.BufferTotal)
|
||||
}
|
||||
|
||||
func TestLogResultFromProto_Nil(t *testing.T) {
|
||||
assert.Nil(t, LogResultFromProto(nil))
|
||||
}
|
||||
|
||||
func TestLogResultFromProto_EmptyLogs(t *testing.T) {
|
||||
proto := &pb.GetSandboxLogsResponse{
|
||||
BufferTotal: 0,
|
||||
}
|
||||
|
||||
result := LogResultFromProto(proto)
|
||||
require.NotNil(t, result)
|
||||
assert.Empty(t, result.Lines)
|
||||
assert.Equal(t, uint32(0), result.BufferTotal)
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1"
|
||||
)
|
||||
|
||||
// --- NetworkPolicyRule ---
|
||||
|
||||
// NetworkPolicyRuleFromProto converts a proto NetworkPolicyRule to an SDK NetworkPolicyRule.
|
||||
func NetworkPolicyRuleFromProto(r *sbv1.NetworkPolicyRule) *types.NetworkPolicyRule {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
result := &types.NetworkPolicyRule{
|
||||
Name: r.GetName(),
|
||||
}
|
||||
if eps := r.GetEndpoints(); len(eps) > 0 {
|
||||
result.Endpoints = make([]types.PolicyNetworkEndpoint, len(eps))
|
||||
for i, ep := range eps {
|
||||
if ep != nil {
|
||||
result.Endpoints[i] = policyNetworkEndpointFromProto(ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bins := r.GetBinaries(); len(bins) > 0 {
|
||||
result.Binaries = make([]types.PolicyNetworkBinary, len(bins))
|
||||
for i, b := range bins {
|
||||
if b != nil {
|
||||
result.Binaries[i] = types.PolicyNetworkBinary{Path: b.GetPath()}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// NetworkPolicyRuleToProto converts an SDK NetworkPolicyRule to a proto NetworkPolicyRule.
|
||||
func NetworkPolicyRuleToProto(r *types.NetworkPolicyRule) *sbv1.NetworkPolicyRule {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
result := &sbv1.NetworkPolicyRule{
|
||||
Name: r.Name,
|
||||
}
|
||||
if len(r.Endpoints) > 0 {
|
||||
result.Endpoints = make([]*sbv1.NetworkEndpoint, len(r.Endpoints))
|
||||
for i := range r.Endpoints {
|
||||
result.Endpoints[i] = policyNetworkEndpointToProto(&r.Endpoints[i])
|
||||
}
|
||||
}
|
||||
if len(r.Binaries) > 0 {
|
||||
result.Binaries = make([]*sbv1.NetworkBinary, len(r.Binaries))
|
||||
for i := range r.Binaries {
|
||||
result.Binaries[i] = &sbv1.NetworkBinary{Path: r.Binaries[i].Path}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- PolicyNetworkEndpoint ---
|
||||
|
||||
func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetworkEndpoint {
|
||||
result := types.PolicyNetworkEndpoint{
|
||||
Host: ep.GetHost(),
|
||||
Port: ep.GetPort(),
|
||||
Protocol: ep.GetProtocol(),
|
||||
TLS: ep.GetTls(),
|
||||
Enforcement: ep.GetEnforcement(),
|
||||
Access: ep.GetAccess(),
|
||||
AllowEncodedSlash: ep.GetAllowEncodedSlash(),
|
||||
PersistedQueries: ep.GetPersistedQueries(),
|
||||
GraphqlMaxBodyBytes: ep.GetGraphqlMaxBodyBytes(),
|
||||
Path: ep.GetPath(),
|
||||
WebsocketCredentialRewrite: ep.GetWebsocketCredentialRewrite(),
|
||||
RequestBodyCredentialRewrite: ep.GetRequestBodyCredentialRewrite(),
|
||||
AdvisorProposed: ep.GetAdvisorProposed(),
|
||||
CredentialSigning: ep.GetCredentialSigning(),
|
||||
SigningService: ep.GetSigningService(),
|
||||
SigningRegion: ep.GetSigningRegion(),
|
||||
JsonRpcMaxBodyBytes: ep.GetJsonRpcMaxBodyBytes(),
|
||||
}
|
||||
if mcp := ep.GetMcp(); mcp != nil {
|
||||
result.Mcp = mcpOptionsFromProto(mcp)
|
||||
}
|
||||
if ports := ep.GetPorts(); len(ports) > 0 {
|
||||
result.Ports = make([]uint32, len(ports))
|
||||
copy(result.Ports, ports)
|
||||
}
|
||||
if ips := ep.GetAllowedIps(); len(ips) > 0 {
|
||||
result.AllowedIPs = CopyStringSlice(ips)
|
||||
}
|
||||
if rules := ep.GetRules(); len(rules) > 0 {
|
||||
result.Rules = make([]types.L7Rule, len(rules))
|
||||
for i, r := range rules {
|
||||
if r != nil {
|
||||
result.Rules[i] = l7RuleFromProto(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if deny := ep.GetDenyRules(); len(deny) > 0 {
|
||||
result.DenyRules = make([]types.L7DenyRule, len(deny))
|
||||
for i, r := range deny {
|
||||
if r != nil {
|
||||
result.DenyRules[i] = l7DenyRuleFromProto(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if gql := ep.GetGraphqlPersistedQueries(); len(gql) > 0 {
|
||||
result.GraphqlPersistedQueries = make(map[string]types.GraphqlOperation, len(gql))
|
||||
for k, v := range gql {
|
||||
if v != nil {
|
||||
result.GraphqlPersistedQueries[k] = graphqlOperationFromProto(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.NetworkEndpoint {
|
||||
result := &sbv1.NetworkEndpoint{
|
||||
Host: ep.Host,
|
||||
Port: ep.Port,
|
||||
Protocol: ep.Protocol,
|
||||
Tls: ep.TLS,
|
||||
Enforcement: ep.Enforcement,
|
||||
Access: ep.Access,
|
||||
AllowEncodedSlash: ep.AllowEncodedSlash,
|
||||
PersistedQueries: ep.PersistedQueries,
|
||||
GraphqlMaxBodyBytes: ep.GraphqlMaxBodyBytes,
|
||||
Path: ep.Path,
|
||||
WebsocketCredentialRewrite: ep.WebsocketCredentialRewrite,
|
||||
RequestBodyCredentialRewrite: ep.RequestBodyCredentialRewrite,
|
||||
AdvisorProposed: ep.AdvisorProposed,
|
||||
CredentialSigning: ep.CredentialSigning,
|
||||
SigningService: ep.SigningService,
|
||||
SigningRegion: ep.SigningRegion,
|
||||
JsonRpcMaxBodyBytes: ep.JsonRpcMaxBodyBytes,
|
||||
}
|
||||
if ep.Mcp != nil {
|
||||
result.Mcp = mcpOptionsToProto(ep.Mcp)
|
||||
}
|
||||
if len(ep.Ports) > 0 {
|
||||
result.Ports = make([]uint32, len(ep.Ports))
|
||||
copy(result.Ports, ep.Ports)
|
||||
}
|
||||
if len(ep.AllowedIPs) > 0 {
|
||||
result.AllowedIps = CopyStringSlice(ep.AllowedIPs)
|
||||
}
|
||||
if len(ep.Rules) > 0 {
|
||||
result.Rules = make([]*sbv1.L7Rule, len(ep.Rules))
|
||||
for i := range ep.Rules {
|
||||
result.Rules[i] = l7RuleToProto(&ep.Rules[i])
|
||||
}
|
||||
}
|
||||
if len(ep.DenyRules) > 0 {
|
||||
result.DenyRules = make([]*sbv1.L7DenyRule, len(ep.DenyRules))
|
||||
for i := range ep.DenyRules {
|
||||
result.DenyRules[i] = l7DenyRuleToProto(&ep.DenyRules[i])
|
||||
}
|
||||
}
|
||||
if len(ep.GraphqlPersistedQueries) > 0 {
|
||||
result.GraphqlPersistedQueries = make(map[string]*sbv1.GraphqlOperation, len(ep.GraphqlPersistedQueries))
|
||||
for k, v := range ep.GraphqlPersistedQueries {
|
||||
result.GraphqlPersistedQueries[k] = graphqlOperationToProto(&v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- L7Rule ---
|
||||
|
||||
func l7RuleFromProto(r *sbv1.L7Rule) types.L7Rule {
|
||||
result := types.L7Rule{}
|
||||
if a := r.GetAllow(); a != nil {
|
||||
result.Allow = &types.L7Allow{
|
||||
Method: a.GetMethod(),
|
||||
Path: a.GetPath(),
|
||||
Command: a.GetCommand(),
|
||||
OperationType: a.GetOperationType(),
|
||||
OperationName: a.GetOperationName(),
|
||||
Fields: CopyStringSlice(a.GetFields()),
|
||||
}
|
||||
if q := a.GetQuery(); len(q) > 0 {
|
||||
result.Allow.Query = l7QueryMapFromProto(q)
|
||||
}
|
||||
if p := a.GetParams(); len(p) > 0 {
|
||||
result.Allow.Params = l7QueryMapFromProto(p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func l7RuleToProto(r *types.L7Rule) *sbv1.L7Rule {
|
||||
result := &sbv1.L7Rule{}
|
||||
if r.Allow != nil {
|
||||
result.Allow = &sbv1.L7Allow{
|
||||
Method: r.Allow.Method,
|
||||
Path: r.Allow.Path,
|
||||
Command: r.Allow.Command,
|
||||
OperationType: r.Allow.OperationType,
|
||||
OperationName: r.Allow.OperationName,
|
||||
Fields: CopyStringSlice(r.Allow.Fields),
|
||||
}
|
||||
if len(r.Allow.Query) > 0 {
|
||||
result.Allow.Query = l7QueryMapToProto(r.Allow.Query)
|
||||
}
|
||||
if len(r.Allow.Params) > 0 {
|
||||
result.Allow.Params = l7QueryMapToProto(r.Allow.Params)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- L7DenyRule ---
|
||||
|
||||
func l7DenyRuleFromProto(r *sbv1.L7DenyRule) types.L7DenyRule {
|
||||
result := types.L7DenyRule{
|
||||
Method: r.GetMethod(),
|
||||
Path: r.GetPath(),
|
||||
Command: r.GetCommand(),
|
||||
OperationType: r.GetOperationType(),
|
||||
OperationName: r.GetOperationName(),
|
||||
Fields: CopyStringSlice(r.GetFields()),
|
||||
Query: l7QueryMapFromProtoDeny(r.GetQuery()),
|
||||
}
|
||||
if p := r.GetParams(); len(p) > 0 {
|
||||
result.Params = l7QueryMapFromProto(p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func l7DenyRuleToProto(r *types.L7DenyRule) *sbv1.L7DenyRule {
|
||||
result := &sbv1.L7DenyRule{
|
||||
Method: r.Method,
|
||||
Path: r.Path,
|
||||
Command: r.Command,
|
||||
OperationType: r.OperationType,
|
||||
OperationName: r.OperationName,
|
||||
Fields: CopyStringSlice(r.Fields),
|
||||
}
|
||||
if len(r.Query) > 0 {
|
||||
result.Query = l7QueryMapToProtoDeny(r.Query)
|
||||
}
|
||||
if len(r.Params) > 0 {
|
||||
result.Params = l7QueryMapToProto(r.Params)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- L7QueryMatcher helpers ---
|
||||
|
||||
func l7QueryMapFromProto(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]types.L7QueryMatcher, len(m))
|
||||
for k, v := range m {
|
||||
if v != nil {
|
||||
result[k] = types.L7QueryMatcher{
|
||||
Glob: v.GetGlob(),
|
||||
Any: CopyStringSlice(v.GetAny()),
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func l7QueryMapToProto(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]*sbv1.L7QueryMatcher, len(m))
|
||||
for k, v := range m {
|
||||
result[k] = &sbv1.L7QueryMatcher{
|
||||
Glob: v.Glob,
|
||||
Any: CopyStringSlice(v.Any),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// L7DenyRule uses the same L7QueryMatcher proto type but on a different message.
|
||||
func l7QueryMapFromProtoDeny(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher {
|
||||
return l7QueryMapFromProto(m)
|
||||
}
|
||||
|
||||
func l7QueryMapToProtoDeny(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher {
|
||||
return l7QueryMapToProto(m)
|
||||
}
|
||||
|
||||
// --- GraphqlOperation ---
|
||||
|
||||
func graphqlOperationFromProto(op *sbv1.GraphqlOperation) types.GraphqlOperation {
|
||||
return types.GraphqlOperation{
|
||||
OperationType: op.GetOperationType(),
|
||||
OperationName: op.GetOperationName(),
|
||||
Fields: CopyStringSlice(op.GetFields()),
|
||||
}
|
||||
}
|
||||
|
||||
func graphqlOperationToProto(op *types.GraphqlOperation) *sbv1.GraphqlOperation {
|
||||
return &sbv1.GraphqlOperation{
|
||||
OperationType: op.OperationType,
|
||||
OperationName: op.OperationName,
|
||||
Fields: CopyStringSlice(op.Fields),
|
||||
}
|
||||
}
|
||||
|
||||
// --- McpOptions ---
|
||||
|
||||
func mcpOptionsFromProto(m *sbv1.McpOptions) *types.McpOptions {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.McpOptions{
|
||||
StrictToolNames: m.StrictToolNames,
|
||||
AllowAllKnownMcpMethods: m.AllowAllKnownMcpMethods,
|
||||
}
|
||||
}
|
||||
|
||||
func mcpOptionsToProto(m *types.McpOptions) *sbv1.McpOptions {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &sbv1.McpOptions{
|
||||
StrictToolNames: m.StrictToolNames,
|
||||
AllowAllKnownMcpMethods: m.AllowAllKnownMcpMethods,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
|
||||
sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1"
|
||||
)
|
||||
|
||||
// --- PolicyLoadStatus enum mapping ---
|
||||
|
||||
// PolicyLoadStatusFromProto converts a proto PolicyStatus to an SDK PolicyLoadStatus.
|
||||
func PolicyLoadStatusFromProto(s pb.PolicyStatus) types.PolicyLoadStatus {
|
||||
switch s {
|
||||
case pb.PolicyStatus_POLICY_STATUS_PENDING:
|
||||
return types.PolicyLoadStatusPending
|
||||
case pb.PolicyStatus_POLICY_STATUS_LOADED:
|
||||
return types.PolicyLoadStatusLoaded
|
||||
case pb.PolicyStatus_POLICY_STATUS_FAILED:
|
||||
return types.PolicyLoadStatusFailed
|
||||
case pb.PolicyStatus_POLICY_STATUS_SUPERSEDED:
|
||||
return types.PolicyLoadStatusSuperseded
|
||||
default:
|
||||
return types.PolicyLoadStatusUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
// PolicyLoadStatusToProto converts an SDK PolicyLoadStatus to a proto PolicyStatus.
|
||||
func PolicyLoadStatusToProto(s types.PolicyLoadStatus) pb.PolicyStatus {
|
||||
switch s {
|
||||
case types.PolicyLoadStatusPending:
|
||||
return pb.PolicyStatus_POLICY_STATUS_PENDING
|
||||
case types.PolicyLoadStatusLoaded:
|
||||
return pb.PolicyStatus_POLICY_STATUS_LOADED
|
||||
case types.PolicyLoadStatusFailed:
|
||||
return pb.PolicyStatus_POLICY_STATUS_FAILED
|
||||
case types.PolicyLoadStatusSuperseded:
|
||||
return pb.PolicyStatus_POLICY_STATUS_SUPERSEDED
|
||||
default:
|
||||
return pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
// --- PolicyChunk ---
|
||||
|
||||
// PolicyChunkFromProto converts a proto PolicyChunk to an SDK PolicyChunk.
|
||||
func PolicyChunkFromProto(c *pb.PolicyChunk) *types.PolicyChunk {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.PolicyChunk{
|
||||
ID: c.GetId(),
|
||||
Status: c.GetStatus(),
|
||||
RuleName: c.GetRuleName(),
|
||||
ProposedRule: NetworkPolicyRuleFromProto(c.GetProposedRule()),
|
||||
Rationale: c.GetRationale(),
|
||||
SecurityNotes: c.GetSecurityNotes(),
|
||||
Confidence: c.GetConfidence(),
|
||||
DenialSummaryIDs: CopyStringSlice(c.GetDenialSummaryIds()),
|
||||
CreatedAt: TimeFromMillis(c.GetCreatedAtMs()),
|
||||
DecidedAt: TimeFromMillis(c.GetDecidedAtMs()),
|
||||
Stage: c.GetStage(),
|
||||
SupersedesChunkID: c.GetSupersedesChunkId(),
|
||||
HitCount: c.GetHitCount(),
|
||||
FirstSeen: TimeFromMillis(c.GetFirstSeenMs()),
|
||||
LastSeen: TimeFromMillis(c.GetLastSeenMs()),
|
||||
Binary: c.GetBinary(),
|
||||
ValidationResult: c.GetValidationResult(),
|
||||
RejectionReason: c.GetRejectionReason(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- DraftPolicy ---
|
||||
|
||||
// DraftPolicyFromProto converts a proto GetDraftPolicyResponse to an SDK DraftPolicy.
|
||||
func DraftPolicyFromProto(r *pb.GetDraftPolicyResponse) *types.DraftPolicy {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
result := &types.DraftPolicy{
|
||||
RollingSummary: r.GetRollingSummary(),
|
||||
DraftVersion: r.GetDraftVersion(),
|
||||
LastAnalyzedAt: TimeFromMillis(r.GetLastAnalyzedAtMs()),
|
||||
}
|
||||
if chunks := r.GetChunks(); len(chunks) > 0 {
|
||||
result.Chunks = make([]types.PolicyChunk, 0, len(chunks))
|
||||
for _, c := range chunks {
|
||||
if converted := PolicyChunkFromProto(c); converted != nil {
|
||||
result.Chunks = append(result.Chunks, *converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- SandboxPolicy ---
|
||||
|
||||
// SandboxPolicyFromProto converts a proto SandboxPolicy to an SDK SandboxPolicy.
|
||||
// Returns nil for nil input. All slice and map fields are deep-copied.
|
||||
func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
result := &types.SandboxPolicy{
|
||||
Version: p.GetVersion(),
|
||||
Filesystem: filesystemPolicyFromProto(p.GetFilesystem()),
|
||||
Landlock: landlockPolicyFromProto(p.GetLandlock()),
|
||||
Process: processPolicyFromProto(p.GetProcess()),
|
||||
}
|
||||
if np := p.GetNetworkPolicies(); np != nil {
|
||||
result.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(np))
|
||||
for k, v := range np {
|
||||
if converted := NetworkPolicyRuleFromProto(v); converted != nil {
|
||||
result.NetworkPolicies[k] = *converted
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SandboxPolicyToProto converts an SDK SandboxPolicy to a proto SandboxPolicy.
|
||||
// Returns nil for nil input. All slice and map fields are deep-copied.
|
||||
func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
result := &sbv1.SandboxPolicy{
|
||||
Version: p.Version,
|
||||
Filesystem: filesystemPolicyToProto(p.Filesystem),
|
||||
Landlock: landlockPolicyToProto(p.Landlock),
|
||||
Process: processPolicyToProto(p.Process),
|
||||
}
|
||||
if p.NetworkPolicies != nil {
|
||||
result.NetworkPolicies = make(map[string]*sbv1.NetworkPolicyRule, len(p.NetworkPolicies))
|
||||
for k, v := range p.NetworkPolicies {
|
||||
result.NetworkPolicies[k] = NetworkPolicyRuleToProto(&v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func filesystemPolicyFromProto(f *sbv1.FilesystemPolicy) *types.FilesystemPolicy {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.FilesystemPolicy{
|
||||
IncludeWorkdir: f.GetIncludeWorkdir(),
|
||||
ReadOnly: CopyStringSlice(f.GetReadOnly()),
|
||||
ReadWrite: CopyStringSlice(f.GetReadWrite()),
|
||||
}
|
||||
}
|
||||
|
||||
func filesystemPolicyToProto(f *types.FilesystemPolicy) *sbv1.FilesystemPolicy {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return &sbv1.FilesystemPolicy{
|
||||
IncludeWorkdir: f.IncludeWorkdir,
|
||||
ReadOnly: CopyStringSlice(f.ReadOnly),
|
||||
ReadWrite: CopyStringSlice(f.ReadWrite),
|
||||
}
|
||||
}
|
||||
|
||||
func landlockPolicyFromProto(l *sbv1.LandlockPolicy) *types.LandlockPolicy {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.LandlockPolicy{
|
||||
Compatibility: l.GetCompatibility(),
|
||||
}
|
||||
}
|
||||
|
||||
func landlockPolicyToProto(l *types.LandlockPolicy) *sbv1.LandlockPolicy {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return &sbv1.LandlockPolicy{
|
||||
Compatibility: l.Compatibility,
|
||||
}
|
||||
}
|
||||
|
||||
func processPolicyFromProto(p *sbv1.ProcessPolicy) *types.ProcessPolicy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.ProcessPolicy{
|
||||
RunAsUser: p.GetRunAsUser(),
|
||||
RunAsGroup: p.GetRunAsGroup(),
|
||||
}
|
||||
}
|
||||
|
||||
func processPolicyToProto(p *types.ProcessPolicy) *sbv1.ProcessPolicy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &sbv1.ProcessPolicy{
|
||||
RunAsUser: p.RunAsUser,
|
||||
RunAsGroup: p.RunAsGroup,
|
||||
}
|
||||
}
|
||||
|
||||
// --- SandboxPolicyRevision ---
|
||||
|
||||
// SandboxPolicyRevisionFromProto converts a proto SandboxPolicyRevision to an SDK SandboxPolicyRevision.
|
||||
func SandboxPolicyRevisionFromProto(r *pb.SandboxPolicyRevision) *types.SandboxPolicyRevision {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.SandboxPolicyRevision{
|
||||
Version: r.GetVersion(),
|
||||
PolicyHash: r.GetPolicyHash(),
|
||||
Status: PolicyLoadStatusFromProto(r.GetStatus()),
|
||||
LoadError: r.GetLoadError(),
|
||||
CreatedAt: TimeFromMillis(r.GetCreatedAtMs()),
|
||||
LoadedAt: TimeFromMillis(r.GetLoadedAtMs()),
|
||||
Policy: SandboxPolicyFromProto(r.GetPolicy()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- PolicyStatusResult ---
|
||||
|
||||
// PolicyStatusResultFromProto converts a proto GetSandboxPolicyStatusResponse to an SDK PolicyStatusResult.
|
||||
func PolicyStatusResultFromProto(r *pb.GetSandboxPolicyStatusResponse) *types.PolicyStatusResult {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
result := &types.PolicyStatusResult{
|
||||
ActiveVersion: r.GetActiveVersion(),
|
||||
}
|
||||
if rev := SandboxPolicyRevisionFromProto(r.GetRevision()); rev != nil {
|
||||
result.Revision = *rev
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- ApproveResult ---
|
||||
|
||||
// ApproveResultFromProto converts a proto ApproveDraftChunkResponse to an SDK ApproveResult.
|
||||
func ApproveResultFromProto(r *pb.ApproveDraftChunkResponse) *types.ApproveResult {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.ApproveResult{
|
||||
PolicyVersion: r.GetPolicyVersion(),
|
||||
PolicyHash: r.GetPolicyHash(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- ApproveAllResult ---
|
||||
|
||||
// ApproveAllResultFromProto converts a proto ApproveAllDraftChunksResponse to an SDK ApproveAllResult.
|
||||
func ApproveAllResultFromProto(r *pb.ApproveAllDraftChunksResponse) *types.ApproveAllResult {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.ApproveAllResult{
|
||||
PolicyVersion: r.GetPolicyVersion(),
|
||||
PolicyHash: r.GetPolicyHash(),
|
||||
ChunksApproved: r.GetChunksApproved(),
|
||||
ChunksSkipped: r.GetChunksSkipped(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- UndoResult ---
|
||||
|
||||
// UndoResultFromProto converts a proto UndoDraftChunkResponse to an SDK UndoResult.
|
||||
func UndoResultFromProto(r *pb.UndoDraftChunkResponse) *types.UndoResult {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.UndoResult{
|
||||
PolicyVersion: r.GetPolicyVersion(),
|
||||
PolicyHash: r.GetPolicyHash(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- ClearResult ---
|
||||
|
||||
// ClearResultFromProto converts a proto ClearDraftChunksResponse to an SDK ClearResult.
|
||||
func ClearResultFromProto(r *pb.ClearDraftChunksResponse) *types.ClearResult {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.ClearResult{
|
||||
ChunksCleared: r.GetChunksCleared(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- DraftHistoryEntry ---
|
||||
|
||||
// DraftHistoryEntryFromProto converts a proto DraftHistoryEntry to an SDK DraftHistoryEntry.
|
||||
func DraftHistoryEntryFromProto(e *pb.DraftHistoryEntry) *types.DraftHistoryEntry {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return &types.DraftHistoryEntry{
|
||||
Timestamp: TimeFromMillis(e.GetTimestampMs()),
|
||||
EventType: e.GetEventType(),
|
||||
Description: e.GetDescription(),
|
||||
ChunkID: e.GetChunkId(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
|
||||
)
|
||||
|
||||
// ProviderFromProto converts a proto Provider to an SDK Provider.
|
||||
func ProviderFromProto(p *dm.Provider) *types.Provider {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &types.Provider{
|
||||
Type: p.GetType(),
|
||||
Spec: types.ProviderSpec{
|
||||
Config: CopyStringMap(p.GetConfig()),
|
||||
ProfileWorkspace: p.GetProfileWorkspace(),
|
||||
},
|
||||
}
|
||||
|
||||
if m := p.GetMetadata(); m != nil {
|
||||
result.ID = m.GetId()
|
||||
result.Name = m.GetName()
|
||||
result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs())
|
||||
result.Labels = CopyStringMap(m.GetLabels())
|
||||
result.Annotations = CopyStringMap(m.GetAnnotations())
|
||||
result.ResourceVersion = m.GetResourceVersion()
|
||||
result.Workspace = m.GetWorkspace()
|
||||
result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs())
|
||||
}
|
||||
|
||||
if expires := p.GetCredentialExpiresAtMs(); len(expires) > 0 {
|
||||
result.Spec.CredentialExpiresAt = make(map[string]time.Time, len(expires))
|
||||
for k, ms := range expires {
|
||||
result.Spec.CredentialExpiresAt[k] = TimeFromMillis(ms)
|
||||
}
|
||||
}
|
||||
|
||||
if handles := p.GetCredentialHandles(); len(handles) > 0 {
|
||||
result.Spec.CredentialHandles = make(map[string]types.CredentialHandle, len(handles))
|
||||
for k, h := range handles {
|
||||
result.Spec.CredentialHandles[k] = types.CredentialHandle{
|
||||
Driver: h.GetDriver(),
|
||||
Handle: h.GetHandle(),
|
||||
Metadata: CopyStringMap(h.GetMetadata()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ProviderToProto converts an SDK Provider to a proto Provider.
|
||||
func ProviderToProto(p *types.Provider) *dm.Provider {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &dm.Provider{
|
||||
Metadata: &dm.ObjectMeta{
|
||||
Id: p.ID,
|
||||
Name: p.Name,
|
||||
CreatedAtMs: MillisFromTime(p.CreatedAt),
|
||||
Labels: CopyStringMap(p.Labels),
|
||||
Annotations: CopyStringMap(p.Annotations),
|
||||
ResourceVersion: p.ResourceVersion,
|
||||
Workspace: p.Workspace,
|
||||
DeletionTimestampMs: MillisFromTimePtr(p.DeletionTimestamp),
|
||||
},
|
||||
Type: p.Type,
|
||||
Credentials: CopyStringMap(p.Spec.Credentials),
|
||||
Config: CopyStringMap(p.Spec.Config),
|
||||
ProfileWorkspace: p.Spec.ProfileWorkspace,
|
||||
}
|
||||
|
||||
if len(p.Spec.CredentialExpiresAt) > 0 {
|
||||
result.CredentialExpiresAtMs = make(map[string]int64, len(p.Spec.CredentialExpiresAt))
|
||||
for k, t := range p.Spec.CredentialExpiresAt {
|
||||
result.CredentialExpiresAtMs[k] = MillisFromTime(t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.Spec.CredentialHandles) > 0 {
|
||||
result.CredentialHandles = make(map[string]*dm.CredentialHandle, len(p.Spec.CredentialHandles))
|
||||
for k, h := range p.Spec.CredentialHandles {
|
||||
result.CredentialHandles[k] = &dm.CredentialHandle{
|
||||
Driver: h.Driver,
|
||||
Handle: h.Handle,
|
||||
Metadata: CopyStringMap(h.Metadata),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProviderFromProto_Nil(t *testing.T) {
|
||||
assert.Nil(t, ProviderFromProto(nil))
|
||||
}
|
||||
|
||||
func TestProviderFromProto_Full(t *testing.T) {
|
||||
proto := &dm.Provider{
|
||||
Metadata: &dm.ObjectMeta{
|
||||
Id: "prov-1",
|
||||
Name: "claude-provider",
|
||||
CreatedAtMs: 1700000000000,
|
||||
Labels: map[string]string{"env": "prod"},
|
||||
Annotations: map[string]string{"note": "test"},
|
||||
ResourceVersion: 42,
|
||||
Workspace: "default",
|
||||
},
|
||||
Type: "claude",
|
||||
Credentials: map[string]string{"api_key": "secret"},
|
||||
Config: map[string]string{"base_url": "https://api.example.com"},
|
||||
ProfileWorkspace: "shared",
|
||||
CredentialExpiresAtMs: map[string]int64{
|
||||
"api_key": 1700003600000,
|
||||
},
|
||||
CredentialHandles: map[string]*dm.CredentialHandle{
|
||||
"api_key": {
|
||||
Driver: "vault",
|
||||
Handle: "secret/data/claude",
|
||||
Metadata: map[string]string{"version": "3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ProviderFromProto(proto)
|
||||
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "prov-1", result.ID)
|
||||
assert.Equal(t, "claude-provider", result.Name)
|
||||
assert.Equal(t, "claude", result.Type)
|
||||
assert.Equal(t, uint64(42), result.ResourceVersion)
|
||||
assert.Equal(t, "default", result.Workspace)
|
||||
assert.Equal(t, map[string]string{"env": "prod"}, result.Labels)
|
||||
assert.Equal(t, map[string]string{"note": "test"}, result.Annotations)
|
||||
assert.Equal(t, map[string]string{"base_url": "https://api.example.com"}, result.Spec.Config)
|
||||
assert.Equal(t, "shared", result.Spec.ProfileWorkspace)
|
||||
|
||||
require.Len(t, result.Spec.CredentialExpiresAt, 1)
|
||||
assert.False(t, result.Spec.CredentialExpiresAt["api_key"].IsZero())
|
||||
|
||||
require.Len(t, result.Spec.CredentialHandles, 1)
|
||||
h := result.Spec.CredentialHandles["api_key"]
|
||||
assert.Equal(t, "vault", h.Driver)
|
||||
assert.Equal(t, "secret/data/claude", h.Handle)
|
||||
assert.Equal(t, map[string]string{"version": "3"}, h.Metadata)
|
||||
}
|
||||
|
||||
func TestProviderFromProto_NilMetadata(t *testing.T) {
|
||||
proto := &dm.Provider{
|
||||
Type: "openai",
|
||||
Config: map[string]string{"key": "val"},
|
||||
}
|
||||
|
||||
result := ProviderFromProto(proto)
|
||||
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "", result.ID)
|
||||
assert.Equal(t, "", result.Name)
|
||||
assert.Equal(t, "openai", result.Type)
|
||||
assert.Equal(t, map[string]string{"key": "val"}, result.Spec.Config)
|
||||
}
|
||||
|
||||
func TestProviderFromProto_EmptyHandles(t *testing.T) {
|
||||
proto := &dm.Provider{
|
||||
Type: "test",
|
||||
CredentialHandles: map[string]*dm.CredentialHandle{},
|
||||
}
|
||||
|
||||
result := ProviderFromProto(proto)
|
||||
|
||||
require.NotNil(t, result)
|
||||
assert.Nil(t, result.Spec.CredentialHandles)
|
||||
}
|
||||
|
||||
func TestProviderToProto_Nil(t *testing.T) {
|
||||
assert.Nil(t, ProviderToProto(nil))
|
||||
}
|
||||
|
||||
func TestProviderToProto_Full(t *testing.T) {
|
||||
expires := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
provider := &types.Provider{
|
||||
ID: "prov-1",
|
||||
Name: "test-provider",
|
||||
Type: "claude",
|
||||
Labels: map[string]string{"env": "dev"},
|
||||
Annotations: map[string]string{"note": "x"},
|
||||
ResourceVersion: 7,
|
||||
Workspace: "ws-1",
|
||||
Spec: types.ProviderSpec{
|
||||
Credentials: map[string]string{"token": "abc"},
|
||||
Config: map[string]string{"url": "https://example.com"},
|
||||
ProfileWorkspace: "global",
|
||||
CredentialExpiresAt: map[string]time.Time{"token": expires},
|
||||
CredentialHandles: map[string]types.CredentialHandle{
|
||||
"token": {
|
||||
Driver: "k8s-secrets",
|
||||
Handle: "ns/secret-name",
|
||||
Metadata: map[string]string{"k": "v"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := ProviderToProto(provider)
|
||||
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "prov-1", result.Metadata.Id)
|
||||
assert.Equal(t, "test-provider", result.Metadata.Name)
|
||||
assert.Equal(t, "claude", result.Type)
|
||||
assert.Equal(t, "global", result.ProfileWorkspace)
|
||||
assert.Equal(t, map[string]string{"token": "abc"}, result.Credentials)
|
||||
assert.Equal(t, map[string]string{"url": "https://example.com"}, result.Config)
|
||||
|
||||
require.Len(t, result.CredentialExpiresAtMs, 1)
|
||||
assert.Greater(t, result.CredentialExpiresAtMs["token"], int64(0))
|
||||
|
||||
require.Len(t, result.CredentialHandles, 1)
|
||||
h := result.CredentialHandles["token"]
|
||||
assert.Equal(t, "k8s-secrets", h.Driver)
|
||||
assert.Equal(t, "ns/secret-name", h.Handle)
|
||||
assert.Equal(t, map[string]string{"k": "v"}, h.Metadata)
|
||||
}
|
||||
|
||||
func TestProviderRoundTrip(t *testing.T) {
|
||||
original := &types.Provider{
|
||||
ID: "rt-1",
|
||||
Name: "roundtrip",
|
||||
Type: "gitlab",
|
||||
ResourceVersion: 3,
|
||||
Workspace: "default",
|
||||
Labels: map[string]string{"team": "infra"},
|
||||
Spec: types.ProviderSpec{
|
||||
Config: map[string]string{"url": "https://gitlab.com"},
|
||||
ProfileWorkspace: "shared",
|
||||
CredentialHandles: map[string]types.CredentialHandle{
|
||||
"pat": {Driver: "vault", Handle: "secret/gitlab", Metadata: map[string]string{"ver": "1"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
proto := ProviderToProto(original)
|
||||
back := ProviderFromProto(proto)
|
||||
|
||||
assert.Equal(t, original.ID, back.ID)
|
||||
assert.Equal(t, original.Name, back.Name)
|
||||
assert.Equal(t, original.Type, back.Type)
|
||||
assert.Equal(t, original.Workspace, back.Workspace)
|
||||
assert.Equal(t, original.Labels, back.Labels)
|
||||
assert.Equal(t, original.Spec.Config, back.Spec.Config)
|
||||
assert.Equal(t, original.Spec.ProfileWorkspace, back.Spec.ProfileWorkspace)
|
||||
assert.Equal(t, original.Spec.CredentialHandles, back.Spec.CredentialHandles)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
|
||||
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
|
||||
)
|
||||
|
||||
// SandboxFromProto converts a proto Sandbox to an SDK Sandbox.
|
||||
func SandboxFromProto(s *pb.Sandbox) *types.Sandbox {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &types.Sandbox{}
|
||||
|
||||
if m := s.GetMetadata(); m != nil {
|
||||
result.ID = m.GetId()
|
||||
result.Name = m.GetName()
|
||||
result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs())
|
||||
result.Labels = CopyStringMap(m.GetLabels())
|
||||
result.Annotations = CopyStringMap(m.GetAnnotations())
|
||||
result.ResourceVersion = m.GetResourceVersion()
|
||||
result.Workspace = m.GetWorkspace()
|
||||
result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs())
|
||||
}
|
||||
|
||||
if spec := s.GetSpec(); spec != nil {
|
||||
result.Spec = sandboxSpecFromProto(spec)
|
||||
}
|
||||
|
||||
if status := s.GetStatus(); status != nil {
|
||||
result.Status = sandboxStatusFromProto(status)
|
||||
} else {
|
||||
result.Status.Phase = types.SandboxUnknown
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec {
|
||||
result := types.SandboxSpec{
|
||||
LogLevel: spec.GetLogLevel(),
|
||||
Environment: CopyStringMap(spec.GetEnvironment()),
|
||||
Providers: CopyStringSlice(spec.GetProviders()),
|
||||
Policy: SandboxPolicyFromProto(spec.GetPolicy()),
|
||||
}
|
||||
|
||||
if tmpl := spec.GetTemplate(); tmpl != nil {
|
||||
result.Template = &types.SandboxTemplate{
|
||||
Image: tmpl.GetImage(),
|
||||
RuntimeClassName: tmpl.GetRuntimeClassName(),
|
||||
AgentSocket: tmpl.GetAgentSocket(),
|
||||
Labels: CopyStringMap(tmpl.GetLabels()),
|
||||
Annotations: CopyStringMap(tmpl.GetAnnotations()),
|
||||
Environment: CopyStringMap(tmpl.GetEnvironment()),
|
||||
Resources: structToMap(tmpl.GetResources()),
|
||||
UserNamespaces: CopyBoolPtr(tmpl.UserNamespaces),
|
||||
DriverConfig: structToMap(tmpl.GetDriverConfig()),
|
||||
}
|
||||
}
|
||||
|
||||
if rr := spec.GetResourceRequirements(); rr != nil {
|
||||
if gpu := rr.GetGpu(); gpu != nil && gpu.Count != nil {
|
||||
result.GPUCount = gpu.Count
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func sandboxStatusFromProto(status *pb.SandboxStatus) types.SandboxStatus {
|
||||
result := types.SandboxStatus{
|
||||
SandboxName: status.GetSandboxName(),
|
||||
AgentPod: status.GetAgentPod(),
|
||||
AgentFd: status.GetAgentFd(),
|
||||
SandboxFd: status.GetSandboxFd(),
|
||||
Phase: SandboxPhaseFromProto(status.GetPhase()),
|
||||
CurrentPolicyVersion: status.GetCurrentPolicyVersion(),
|
||||
}
|
||||
|
||||
for _, c := range status.GetConditions() {
|
||||
result.Conditions = append(result.Conditions, types.SandboxCondition{
|
||||
Type: c.GetType(),
|
||||
Status: c.GetStatus(),
|
||||
Reason: c.GetReason(),
|
||||
Message: c.GetMessage(),
|
||||
LastTransitionTime: c.GetLastTransitionTime(),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SandboxPhaseFromProto converts a proto SandboxPhase to an SDK SandboxPhase.
|
||||
func SandboxPhaseFromProto(phase pb.SandboxPhase) types.SandboxPhase {
|
||||
switch phase {
|
||||
case pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING:
|
||||
return types.SandboxProvisioning
|
||||
case pb.SandboxPhase_SANDBOX_PHASE_READY:
|
||||
return types.SandboxReady
|
||||
case pb.SandboxPhase_SANDBOX_PHASE_ERROR:
|
||||
return types.SandboxError
|
||||
case pb.SandboxPhase_SANDBOX_PHASE_DELETING:
|
||||
return types.SandboxDeleting
|
||||
case pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN:
|
||||
return types.SandboxUnknown
|
||||
default:
|
||||
return types.SandboxUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// SandboxPhaseToProto converts an SDK SandboxPhase to a proto SandboxPhase.
|
||||
func SandboxPhaseToProto(phase types.SandboxPhase) pb.SandboxPhase {
|
||||
switch phase {
|
||||
case types.SandboxProvisioning:
|
||||
return pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING
|
||||
case types.SandboxReady:
|
||||
return pb.SandboxPhase_SANDBOX_PHASE_READY
|
||||
case types.SandboxError:
|
||||
return pb.SandboxPhase_SANDBOX_PHASE_ERROR
|
||||
case types.SandboxDeleting:
|
||||
return pb.SandboxPhase_SANDBOX_PHASE_DELETING
|
||||
case types.SandboxUnknown:
|
||||
return pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN
|
||||
default:
|
||||
return pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// SandboxToProto converts an SDK Sandbox to a proto Sandbox.
|
||||
func SandboxToProto(s *types.Sandbox) (*pb.Sandbox, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
spec, err := SandboxSpecToProto(&s.Spec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert sandbox spec: %w", err)
|
||||
}
|
||||
|
||||
return &pb.Sandbox{
|
||||
Metadata: &dm.ObjectMeta{
|
||||
Id: s.ID,
|
||||
Name: s.Name,
|
||||
CreatedAtMs: MillisFromTime(s.CreatedAt),
|
||||
Labels: CopyStringMap(s.Labels),
|
||||
Annotations: CopyStringMap(s.Annotations),
|
||||
ResourceVersion: s.ResourceVersion,
|
||||
Workspace: s.Workspace,
|
||||
DeletionTimestampMs: MillisFromTimePtr(s.DeletionTimestamp),
|
||||
},
|
||||
Spec: spec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SandboxSpecToProto converts an SDK SandboxSpec to a proto SandboxSpec.
|
||||
func SandboxSpecToProto(spec *types.SandboxSpec) (*pb.SandboxSpec, error) {
|
||||
if spec == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := &pb.SandboxSpec{
|
||||
LogLevel: spec.LogLevel,
|
||||
Environment: CopyStringMap(spec.Environment),
|
||||
Providers: CopyStringSlice(spec.Providers),
|
||||
Policy: SandboxPolicyToProto(spec.Policy),
|
||||
}
|
||||
|
||||
if spec.Template != nil {
|
||||
resources, err := mapToStruct(spec.Template.Resources)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert template resources: %w", err)
|
||||
}
|
||||
driverConfig, err := mapToStruct(spec.Template.DriverConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert template driver config: %w", err)
|
||||
}
|
||||
result.Template = &pb.SandboxTemplate{
|
||||
Image: spec.Template.Image,
|
||||
RuntimeClassName: spec.Template.RuntimeClassName,
|
||||
AgentSocket: spec.Template.AgentSocket,
|
||||
Labels: CopyStringMap(spec.Template.Labels),
|
||||
Annotations: CopyStringMap(spec.Template.Annotations),
|
||||
Environment: CopyStringMap(spec.Template.Environment),
|
||||
Resources: resources,
|
||||
UserNamespaces: CopyBoolPtr(spec.Template.UserNamespaces),
|
||||
DriverConfig: driverConfig,
|
||||
}
|
||||
}
|
||||
|
||||
if spec.GPUCount != nil {
|
||||
result.ResourceRequirements = &pb.ResourceRequirements{
|
||||
Gpu: &pb.GpuResourceRequirements{
|
||||
Count: spec.GPUCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1"
|
||||
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestSandboxFromProto(t *testing.T) {
|
||||
userNS := true
|
||||
gpuCount := uint32(2)
|
||||
proto := &pb.Sandbox{
|
||||
Metadata: &dm.ObjectMeta{
|
||||
Id: "sb-1",
|
||||
Name: "my-sandbox",
|
||||
CreatedAtMs: 1700000000000,
|
||||
Labels: map[string]string{"env": "dev"},
|
||||
Annotations: map[string]string{"owner": "team-a"},
|
||||
ResourceVersion: 3,
|
||||
Workspace: "prod",
|
||||
DeletionTimestampMs: 1700000060000,
|
||||
},
|
||||
Spec: &pb.SandboxSpec{
|
||||
LogLevel: "debug",
|
||||
Environment: map[string]string{"FOO": "bar"},
|
||||
Template: &pb.SandboxTemplate{
|
||||
Image: "nvidia/sandbox:latest",
|
||||
RuntimeClassName: "kata",
|
||||
AgentSocket: "/var/run/agent.sock",
|
||||
Labels: map[string]string{"app": "test"},
|
||||
Annotations: map[string]string{"note": "hello"},
|
||||
Environment: map[string]string{"TMPL_VAR": "val"},
|
||||
UserNamespaces: &userNS,
|
||||
},
|
||||
Providers: []string{"claude", "github"},
|
||||
ResourceRequirements: &pb.ResourceRequirements{
|
||||
Gpu: &pb.GpuResourceRequirements{
|
||||
Count: &gpuCount,
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: &pb.SandboxStatus{
|
||||
SandboxName: "sb-compute-1",
|
||||
AgentPod: "agent-pod-xyz",
|
||||
AgentFd: "fd-agent",
|
||||
SandboxFd: "fd-sandbox",
|
||||
Phase: pb.SandboxPhase_SANDBOX_PHASE_READY,
|
||||
CurrentPolicyVersion: 7,
|
||||
Conditions: []*pb.SandboxCondition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "True",
|
||||
Reason: "AllGood",
|
||||
Message: "Sandbox is ready",
|
||||
LastTransitionTime: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
s := SandboxFromProto(proto)
|
||||
|
||||
require.NotNil(t, s)
|
||||
assert.Equal(t, "sb-1", s.ID)
|
||||
assert.Equal(t, "my-sandbox", s.Name)
|
||||
assert.Equal(t, time.UnixMilli(1700000000000).UTC(), s.CreatedAt)
|
||||
assert.Equal(t, map[string]string{"env": "dev"}, s.Labels)
|
||||
assert.Equal(t, map[string]string{"owner": "team-a"}, s.Annotations)
|
||||
assert.Equal(t, uint64(3), s.ResourceVersion)
|
||||
assert.Equal(t, "prod", s.Workspace)
|
||||
require.NotNil(t, s.DeletionTimestamp)
|
||||
assert.Equal(t, time.UnixMilli(1700000060000).UTC(), *s.DeletionTimestamp)
|
||||
|
||||
// Spec
|
||||
assert.Equal(t, "debug", s.Spec.LogLevel)
|
||||
assert.Equal(t, map[string]string{"FOO": "bar"}, s.Spec.Environment)
|
||||
assert.Equal(t, []string{"claude", "github"}, s.Spec.Providers)
|
||||
require.NotNil(t, s.Spec.GPUCount)
|
||||
assert.Equal(t, uint32(2), *s.Spec.GPUCount)
|
||||
|
||||
// Template
|
||||
require.NotNil(t, s.Spec.Template)
|
||||
assert.Equal(t, "nvidia/sandbox:latest", s.Spec.Template.Image)
|
||||
assert.Equal(t, "kata", s.Spec.Template.RuntimeClassName)
|
||||
assert.Equal(t, "/var/run/agent.sock", s.Spec.Template.AgentSocket)
|
||||
assert.Equal(t, map[string]string{"app": "test"}, s.Spec.Template.Labels)
|
||||
assert.Equal(t, map[string]string{"note": "hello"}, s.Spec.Template.Annotations)
|
||||
assert.Equal(t, map[string]string{"TMPL_VAR": "val"}, s.Spec.Template.Environment)
|
||||
require.NotNil(t, s.Spec.Template.UserNamespaces)
|
||||
assert.True(t, *s.Spec.Template.UserNamespaces)
|
||||
|
||||
// Status
|
||||
assert.Equal(t, "sb-compute-1", s.Status.SandboxName)
|
||||
assert.Equal(t, "agent-pod-xyz", s.Status.AgentPod)
|
||||
assert.Equal(t, "fd-agent", s.Status.AgentFd)
|
||||
assert.Equal(t, "fd-sandbox", s.Status.SandboxFd)
|
||||
assert.Equal(t, v1.SandboxReady, s.Status.Phase)
|
||||
assert.Equal(t, uint32(7), s.Status.CurrentPolicyVersion)
|
||||
require.Len(t, s.Status.Conditions, 1)
|
||||
assert.Equal(t, "Ready", s.Status.Conditions[0].Type)
|
||||
assert.Equal(t, "True", s.Status.Conditions[0].Status)
|
||||
assert.Equal(t, "AllGood", s.Status.Conditions[0].Reason)
|
||||
assert.Equal(t, "Sandbox is ready", s.Status.Conditions[0].Message)
|
||||
assert.Equal(t, "2024-01-01T00:00:00Z", s.Status.Conditions[0].LastTransitionTime)
|
||||
}
|
||||
|
||||
func TestSandboxFromProto_NilFields(t *testing.T) {
|
||||
proto := &pb.Sandbox{}
|
||||
|
||||
s := SandboxFromProto(proto)
|
||||
|
||||
require.NotNil(t, s)
|
||||
assert.Empty(t, s.ID)
|
||||
assert.Empty(t, s.Name)
|
||||
assert.True(t, s.CreatedAt.IsZero())
|
||||
assert.Nil(t, s.Spec.Template)
|
||||
assert.Nil(t, s.Spec.GPUCount)
|
||||
assert.Equal(t, v1.SandboxUnknown, s.Status.Phase)
|
||||
}
|
||||
|
||||
func TestSandboxFromProto_Nil(t *testing.T) {
|
||||
s := SandboxFromProto(nil)
|
||||
assert.Nil(t, s)
|
||||
}
|
||||
|
||||
func TestSandboxPhaseFromProto(t *testing.T) {
|
||||
tests := []struct {
|
||||
proto pb.SandboxPhase
|
||||
expected v1.SandboxPhase
|
||||
}{
|
||||
{pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING, v1.SandboxProvisioning},
|
||||
{pb.SandboxPhase_SANDBOX_PHASE_READY, v1.SandboxReady},
|
||||
{pb.SandboxPhase_SANDBOX_PHASE_ERROR, v1.SandboxError},
|
||||
{pb.SandboxPhase_SANDBOX_PHASE_DELETING, v1.SandboxDeleting},
|
||||
{pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN, v1.SandboxUnknown},
|
||||
{pb.SandboxPhase_SANDBOX_PHASE_UNSPECIFIED, v1.SandboxUnknown},
|
||||
{pb.SandboxPhase(999), v1.SandboxUnknown},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.expected, SandboxPhaseFromProto(tt.proto), "phase %v", tt.proto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxPhaseToProto(t *testing.T) {
|
||||
tests := []struct {
|
||||
sdk v1.SandboxPhase
|
||||
expected pb.SandboxPhase
|
||||
}{
|
||||
{v1.SandboxProvisioning, pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING},
|
||||
{v1.SandboxReady, pb.SandboxPhase_SANDBOX_PHASE_READY},
|
||||
{v1.SandboxError, pb.SandboxPhase_SANDBOX_PHASE_ERROR},
|
||||
{v1.SandboxDeleting, pb.SandboxPhase_SANDBOX_PHASE_DELETING},
|
||||
{v1.SandboxUnknown, pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN},
|
||||
{v1.SandboxPhase("bogus"), pb.SandboxPhase_SANDBOX_PHASE_UNKNOWN},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.expected, SandboxPhaseToProto(tt.sdk), "phase %v", tt.sdk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxToProto(t *testing.T) {
|
||||
userNS := true
|
||||
gpuCount := uint32(4)
|
||||
delTime := time.UnixMilli(1700000060000).UTC()
|
||||
s := &v1.Sandbox{
|
||||
ID: "sb-1",
|
||||
Name: "my-sandbox",
|
||||
CreatedAt: time.UnixMilli(1700000000000).UTC(),
|
||||
Labels: map[string]string{"env": "dev"},
|
||||
Annotations: map[string]string{"owner": "team-a"},
|
||||
ResourceVersion: 3,
|
||||
Workspace: "prod",
|
||||
DeletionTimestamp: &delTime,
|
||||
Spec: v1.SandboxSpec{
|
||||
LogLevel: "info",
|
||||
Environment: map[string]string{"KEY": "val"},
|
||||
Template: &v1.SandboxTemplate{
|
||||
Image: "img:v1",
|
||||
RuntimeClassName: "runc",
|
||||
AgentSocket: "/sock",
|
||||
Labels: map[string]string{"l": "v"},
|
||||
Annotations: map[string]string{"a": "v"},
|
||||
Environment: map[string]string{"E": "V"},
|
||||
UserNamespaces: &userNS,
|
||||
},
|
||||
Providers: []string{"prov-a"},
|
||||
GPUCount: &gpuCount,
|
||||
},
|
||||
}
|
||||
|
||||
p, err := SandboxToProto(s)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, p)
|
||||
require.NotNil(t, p.Metadata)
|
||||
assert.Equal(t, "sb-1", p.Metadata.Id)
|
||||
assert.Equal(t, "my-sandbox", p.Metadata.Name)
|
||||
assert.Equal(t, int64(1700000000000), p.Metadata.CreatedAtMs)
|
||||
assert.Equal(t, map[string]string{"env": "dev"}, p.Metadata.Labels)
|
||||
assert.Equal(t, map[string]string{"owner": "team-a"}, p.Metadata.Annotations)
|
||||
assert.Equal(t, uint64(3), p.Metadata.ResourceVersion)
|
||||
assert.Equal(t, "prod", p.Metadata.Workspace)
|
||||
assert.Equal(t, int64(1700000060000), p.Metadata.DeletionTimestampMs)
|
||||
|
||||
require.NotNil(t, p.Spec)
|
||||
assert.Equal(t, "info", p.Spec.LogLevel)
|
||||
assert.Equal(t, map[string]string{"KEY": "val"}, p.Spec.Environment)
|
||||
assert.Equal(t, []string{"prov-a"}, p.Spec.Providers)
|
||||
|
||||
require.NotNil(t, p.Spec.ResourceRequirements)
|
||||
require.NotNil(t, p.Spec.ResourceRequirements.Gpu)
|
||||
assert.Equal(t, uint32(4), p.Spec.ResourceRequirements.Gpu.GetCount())
|
||||
|
||||
require.NotNil(t, p.Spec.Template)
|
||||
assert.Equal(t, "img:v1", p.Spec.Template.Image)
|
||||
assert.Equal(t, "runc", p.Spec.Template.RuntimeClassName)
|
||||
assert.Equal(t, "/sock", p.Spec.Template.AgentSocket)
|
||||
assert.Equal(t, map[string]string{"l": "v"}, p.Spec.Template.Labels)
|
||||
assert.Equal(t, map[string]string{"a": "v"}, p.Spec.Template.Annotations)
|
||||
assert.Equal(t, map[string]string{"E": "V"}, p.Spec.Template.Environment)
|
||||
require.NotNil(t, p.Spec.Template.UserNamespaces)
|
||||
assert.True(t, *p.Spec.Template.UserNamespaces)
|
||||
}
|
||||
|
||||
func TestSandboxToProto_Nil(t *testing.T) {
|
||||
p, err := SandboxToProto(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, p)
|
||||
}
|
||||
|
||||
func TestSandboxToProto_NilTemplate(t *testing.T) {
|
||||
s := &v1.Sandbox{
|
||||
Spec: v1.SandboxSpec{
|
||||
LogLevel: "warn",
|
||||
},
|
||||
}
|
||||
|
||||
p, err := SandboxToProto(s)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, p)
|
||||
require.NotNil(t, p.Spec)
|
||||
assert.Nil(t, p.Spec.Template)
|
||||
assert.Nil(t, p.Spec.ResourceRequirements)
|
||||
}
|
||||
|
||||
func TestSandboxRoundTrip(t *testing.T) {
|
||||
userNS := false
|
||||
gpuCount := uint32(1)
|
||||
rtDelTime := time.UnixMilli(1700000090000).UTC()
|
||||
original := &v1.Sandbox{
|
||||
ID: "sb-rt",
|
||||
Name: "round-trip",
|
||||
CreatedAt: time.UnixMilli(1700000000000).UTC(),
|
||||
Labels: map[string]string{"team": "platform"},
|
||||
Annotations: map[string]string{"note": "rt-test"},
|
||||
ResourceVersion: 10,
|
||||
Workspace: "staging",
|
||||
DeletionTimestamp: &rtDelTime,
|
||||
Spec: v1.SandboxSpec{
|
||||
LogLevel: "trace",
|
||||
Environment: map[string]string{"A": "B"},
|
||||
Template: &v1.SandboxTemplate{
|
||||
Image: "img:rt",
|
||||
UserNamespaces: &userNS,
|
||||
},
|
||||
Providers: []string{"p1", "p2"},
|
||||
GPUCount: &gpuCount,
|
||||
Policy: &v1.SandboxPolicy{
|
||||
Version: 3,
|
||||
Filesystem: &v1.FilesystemPolicy{
|
||||
IncludeWorkdir: true,
|
||||
ReadOnly: []string{"/etc", "/usr/share"},
|
||||
ReadWrite: []string{"/tmp"},
|
||||
},
|
||||
Landlock: &v1.LandlockPolicy{
|
||||
Compatibility: "best_effort",
|
||||
},
|
||||
Process: &v1.ProcessPolicy{
|
||||
RunAsUser: "sandbox",
|
||||
RunAsGroup: "sandbox-group",
|
||||
},
|
||||
NetworkPolicies: map[string]v1.NetworkPolicyRule{
|
||||
"web": {
|
||||
Name: "web",
|
||||
Endpoints: []v1.PolicyNetworkEndpoint{
|
||||
{Host: "api.example.com", Port: 443, Protocol: "rest"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
p, err := SandboxToProto(original)
|
||||
require.NoError(t, err)
|
||||
back := SandboxFromProto(p)
|
||||
|
||||
assert.Equal(t, original.ID, back.ID)
|
||||
assert.Equal(t, original.Name, back.Name)
|
||||
assert.Equal(t, original.CreatedAt, back.CreatedAt)
|
||||
assert.Equal(t, original.Labels, back.Labels)
|
||||
assert.Equal(t, original.Annotations, back.Annotations)
|
||||
assert.Equal(t, original.ResourceVersion, back.ResourceVersion)
|
||||
assert.Equal(t, original.Workspace, back.Workspace)
|
||||
require.NotNil(t, back.DeletionTimestamp)
|
||||
assert.Equal(t, *original.DeletionTimestamp, *back.DeletionTimestamp)
|
||||
assert.Equal(t, original.Spec.LogLevel, back.Spec.LogLevel)
|
||||
assert.Equal(t, original.Spec.Environment, back.Spec.Environment)
|
||||
assert.Equal(t, original.Spec.Providers, back.Spec.Providers)
|
||||
require.NotNil(t, back.Spec.GPUCount)
|
||||
assert.Equal(t, *original.Spec.GPUCount, *back.Spec.GPUCount)
|
||||
require.NotNil(t, back.Spec.Template)
|
||||
assert.Equal(t, original.Spec.Template.Image, back.Spec.Template.Image)
|
||||
require.NotNil(t, back.Spec.Template.UserNamespaces)
|
||||
assert.Equal(t, *original.Spec.Template.UserNamespaces, *back.Spec.Template.UserNamespaces)
|
||||
|
||||
// Policy round-trip
|
||||
require.NotNil(t, back.Spec.Policy)
|
||||
assert.Equal(t, uint32(3), back.Spec.Policy.Version)
|
||||
require.NotNil(t, back.Spec.Policy.Filesystem)
|
||||
assert.True(t, back.Spec.Policy.Filesystem.IncludeWorkdir)
|
||||
assert.Equal(t, []string{"/etc", "/usr/share"}, back.Spec.Policy.Filesystem.ReadOnly)
|
||||
assert.Equal(t, []string{"/tmp"}, back.Spec.Policy.Filesystem.ReadWrite)
|
||||
require.NotNil(t, back.Spec.Policy.Landlock)
|
||||
assert.Equal(t, "best_effort", back.Spec.Policy.Landlock.Compatibility)
|
||||
require.NotNil(t, back.Spec.Policy.Process)
|
||||
assert.Equal(t, "sandbox", back.Spec.Policy.Process.RunAsUser)
|
||||
assert.Equal(t, "sandbox-group", back.Spec.Policy.Process.RunAsGroup)
|
||||
require.Len(t, back.Spec.Policy.NetworkPolicies, 1)
|
||||
webRule, ok := back.Spec.Policy.NetworkPolicies["web"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "web", webRule.Name)
|
||||
require.Len(t, webRule.Endpoints, 1)
|
||||
assert.Equal(t, "api.example.com", webRule.Endpoints[0].Host)
|
||||
}
|
||||
|
||||
func TestSandboxSpecToProto(t *testing.T) {
|
||||
gpuCount := uint32(3)
|
||||
spec := &v1.SandboxSpec{
|
||||
LogLevel: "debug",
|
||||
Environment: map[string]string{"X": "Y"},
|
||||
Template: &v1.SandboxTemplate{
|
||||
Image: "img:spec",
|
||||
},
|
||||
Providers: []string{"prov"},
|
||||
GPUCount: &gpuCount,
|
||||
Policy: &v1.SandboxPolicy{
|
||||
Version: 2,
|
||||
Filesystem: &v1.FilesystemPolicy{
|
||||
ReadOnly: []string{"/etc"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
p, err := SandboxSpecToProto(spec)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, p)
|
||||
assert.Equal(t, "debug", p.LogLevel)
|
||||
assert.Equal(t, map[string]string{"X": "Y"}, p.Environment)
|
||||
assert.Equal(t, []string{"prov"}, p.Providers)
|
||||
require.NotNil(t, p.ResourceRequirements)
|
||||
assert.Equal(t, uint32(3), p.ResourceRequirements.Gpu.GetCount())
|
||||
require.NotNil(t, p.Template)
|
||||
assert.Equal(t, "img:spec", p.Template.Image)
|
||||
|
||||
// Policy conversion
|
||||
require.NotNil(t, p.Policy)
|
||||
assert.Equal(t, uint32(2), p.Policy.Version)
|
||||
require.NotNil(t, p.Policy.Filesystem)
|
||||
assert.Equal(t, []string{"/etc"}, p.Policy.Filesystem.ReadOnly)
|
||||
}
|
||||
|
||||
func TestSandboxSpecToProto_Nil(t *testing.T) {
|
||||
p, err := SandboxSpecToProto(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, p)
|
||||
}
|
||||
|
||||
func TestSandboxSpecToProto_InvalidMapReturnsError(t *testing.T) {
|
||||
spec := &v1.SandboxSpec{
|
||||
Template: &v1.SandboxTemplate{
|
||||
Image: "img:v1",
|
||||
Resources: map[string]any{"bad": make(chan int)},
|
||||
},
|
||||
}
|
||||
|
||||
p, err := SandboxSpecToProto(spec)
|
||||
require.Error(t, err, "SandboxSpecToProto must return an error for unconvertible map values")
|
||||
assert.Nil(t, p)
|
||||
assert.Contains(t, err.Error(), "convert template resources")
|
||||
}
|
||||
|
||||
// Verify proto import is used (suppress unused import warning).
|
||||
var _ = proto.Marshal
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import "time"
|
||||
|
||||
// TimeFromMillis converts a millisecond epoch timestamp to time.Time.
|
||||
// A zero value returns the zero time.
|
||||
func TimeFromMillis(ms int64) time.Time {
|
||||
if ms == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.UnixMilli(ms).UTC()
|
||||
}
|
||||
|
||||
// MillisFromTime converts a time.Time to a millisecond epoch timestamp.
|
||||
// A zero time returns 0.
|
||||
func MillisFromTime(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.UnixMilli()
|
||||
}
|
||||
|
||||
// TimeFromMillisPtr converts a millisecond epoch timestamp to a *time.Time.
|
||||
// A zero value returns nil (the resource is not being deleted).
|
||||
func TimeFromMillisPtr(ms int64) *time.Time {
|
||||
if ms == 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.UnixMilli(ms).UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
// MillisFromTimePtr converts a *time.Time to a millisecond epoch timestamp.
|
||||
// A nil pointer returns 0.
|
||||
func MillisFromTimePtr(t *time.Time) int64 {
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
return t.UnixMilli()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package converter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTimeFromMillis(t *testing.T) {
|
||||
ms := int64(1719475200000) // 2024-06-27T08:00:00Z
|
||||
tm := TimeFromMillis(ms)
|
||||
assert.Equal(t, 2024, tm.Year())
|
||||
assert.Equal(t, time.June, tm.Month())
|
||||
assert.Equal(t, 27, tm.Day())
|
||||
}
|
||||
|
||||
func TestTimeFromMillis_Zero(t *testing.T) {
|
||||
tm := TimeFromMillis(0)
|
||||
assert.True(t, tm.IsZero())
|
||||
}
|
||||
|
||||
func TestMillisFromTime(t *testing.T) {
|
||||
tm := time.Date(2024, time.June, 27, 12, 0, 0, 0, time.UTC)
|
||||
ms := MillisFromTime(tm)
|
||||
assert.Equal(t, int64(1719489600000), ms)
|
||||
}
|
||||
|
||||
func TestMillisFromTime_Zero(t *testing.T) {
|
||||
ms := MillisFromTime(time.Time{})
|
||||
assert.Equal(t, int64(0), ms)
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
original := time.Date(2025, time.March, 15, 10, 30, 0, 0, time.UTC)
|
||||
ms := MillisFromTime(original)
|
||||
restored := TimeFromMillis(ms)
|
||||
assert.Equal(t, original.Unix(), restored.Unix())
|
||||
}
|
||||
|
||||
func TestTimeFromMillisPtr_NonZero(t *testing.T) {
|
||||
ms := int64(1719475200000)
|
||||
tp := TimeFromMillisPtr(ms)
|
||||
assert.NotNil(t, tp)
|
||||
assert.Equal(t, 2024, tp.Year())
|
||||
}
|
||||
|
||||
func TestTimeFromMillisPtr_Zero(t *testing.T) {
|
||||
tp := TimeFromMillisPtr(0)
|
||||
assert.Nil(t, tp)
|
||||
}
|
||||
|
||||
func TestMillisFromTimePtr_NonNil(t *testing.T) {
|
||||
tm := time.Date(2024, time.June, 27, 12, 0, 0, 0, time.UTC)
|
||||
ms := MillisFromTimePtr(&tm)
|
||||
assert.Equal(t, int64(1719489600000), ms)
|
||||
}
|
||||
|
||||
func TestMillisFromTimePtr_Nil(t *testing.T) {
|
||||
ms := MillisFromTimePtr(nil)
|
||||
assert.Equal(t, int64(0), ms)
|
||||
}
|
||||
|
||||
func TestPtrRoundTrip(t *testing.T) {
|
||||
original := time.Date(2025, time.March, 15, 10, 30, 0, 0, time.UTC)
|
||||
ms := MillisFromTimePtr(&original)
|
||||
restored := TimeFromMillisPtr(ms)
|
||||
assert.NotNil(t, restored)
|
||||
assert.Equal(t, original.Unix(), restored.Unix())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package grpc provides gRPC connection setup utilities.
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// TLSParams holds TLS settings without importing the v1 package.
|
||||
type TLSParams struct {
|
||||
CertFile string
|
||||
KeyFile string
|
||||
CAFile string
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// NewConnection creates a gRPC client connection.
|
||||
// The address may include an http:// or https:// scheme (as written by the
|
||||
// upstream gateway). The scheme drives transport selection: http:// uses
|
||||
// plaintext, https:// or no scheme uses TLS.
|
||||
func NewConnection(address string, tlsCfg *TLSParams, auth credentials.PerRPCCredentials) (*grpc.ClientConn, error) {
|
||||
usePlaintext := false
|
||||
if strings.HasPrefix(address, "http://") {
|
||||
usePlaintext = true
|
||||
address = strings.TrimPrefix(address, "http://")
|
||||
} else {
|
||||
address = strings.TrimPrefix(address, "https://")
|
||||
}
|
||||
opts := []grpc.DialOption{}
|
||||
|
||||
if usePlaintext {
|
||||
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
} else if tlsCfg != nil {
|
||||
creds, err := buildTLSCredentials(tlsCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tls config: %w", err)
|
||||
}
|
||||
opts = append(opts, grpc.WithTransportCredentials(creds))
|
||||
} else {
|
||||
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12})))
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
if usePlaintext && auth.RequireTransportSecurity() {
|
||||
return nil, fmt.Errorf("grpc connect: auth provider requires transport security but address uses plaintext (http://)")
|
||||
}
|
||||
opts = append(opts, grpc.WithPerRPCCredentials(auth))
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(address, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grpc connect: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func buildTLSCredentials(cfg *TLSParams) (credentials.TransportCredentials, error) {
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: cfg.Insecure, //nolint:gosec // user-requested skip for dev gateways
|
||||
}
|
||||
|
||||
if cfg.CAFile != "" {
|
||||
caCert, err := os.ReadFile(cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read CA file: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("invalid CA certificate")
|
||||
}
|
||||
tlsConfig.RootCAs = pool
|
||||
}
|
||||
|
||||
if cfg.CertFile != "" && cfg.KeyFile != "" {
|
||||
cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load client cert: %w", err)
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||
} else if cfg.CertFile != "" || cfg.KeyFile != "" {
|
||||
return nil, fmt.Errorf("both CertFile and KeyFile must be provided for client certificate authentication")
|
||||
}
|
||||
|
||||
return credentials.NewTLS(tlsConfig), nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func TestNewConnectionHTTPSchemeUsesPlaintext(t *testing.T) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer func() { _ = lis.Close() }()
|
||||
|
||||
srv := grpc.NewServer()
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
defer srv.Stop()
|
||||
|
||||
conn, err := NewConnection("http://"+lis.Addr().String(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConnection with http:// scheme failed: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
}
|
||||
|
||||
func TestNewConnectionHTTPSSchemeUsesTLS(t *testing.T) {
|
||||
// https:// with nil TLS config should default to system TLS.
|
||||
// We cannot dial a real TLS server here, but we can verify the
|
||||
// connection is created (it will fail on handshake, not on dial).
|
||||
conn, err := NewConnection("https://127.0.0.1:1", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConnection with https:// scheme should not fail on create: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
}
|
||||
|
||||
func TestNewConnectionNoSchemeUsesTLS(t *testing.T) {
|
||||
conn, err := NewConnection("127.0.0.1:1", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConnection without scheme should not fail on create: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
}
|
||||
|
||||
func TestNewConnectionInsecureTLSConfig(t *testing.T) {
|
||||
// Insecure: true means TLS with InsecureSkipVerify, not plaintext.
|
||||
// We can verify the connection is created (handshake will fail since
|
||||
// the server is not TLS, but NewClient itself should succeed).
|
||||
conn, err := NewConnection("127.0.0.1:1", &TLSParams{Insecure: true}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConnection with Insecure TLS config failed: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
}
|
||||
|
||||
func TestNewConnectionHTTPWithSecureAuthRejects(t *testing.T) {
|
||||
auth := &testTokenAuth{token: "dev-token", requireSecurity: true}
|
||||
_, err := NewConnection("http://127.0.0.1:1", nil, auth)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when using http:// with auth that requires transport security")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConnectionHTTPWithInsecureAuth(t *testing.T) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer func() { _ = lis.Close() }()
|
||||
|
||||
srv := grpc.NewServer(grpc.Creds(insecure.NewCredentials()))
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
defer srv.Stop()
|
||||
|
||||
auth := &testTokenAuth{token: "dev-token", requireSecurity: false}
|
||||
conn, err := NewConnection("http://"+lis.Addr().String(), nil, auth)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConnection with http:// + insecure auth failed: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
}
|
||||
|
||||
type testTokenAuth struct {
|
||||
token string
|
||||
requireSecurity bool
|
||||
}
|
||||
|
||||
func (a *testTokenAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
|
||||
return map[string]string{"authorization": "Bearer " + a.token}, nil
|
||||
}
|
||||
|
||||
func (a *testTokenAuth) RequireTransportSecurity() bool {
|
||||
return a.requireSecurity
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// Logger defines structured logging for the SDK. Compatible with logr.Logger
|
||||
// and slog.Logger adapters.
|
||||
type Logger = types.Logger
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// CreateOptions configures resource creation.
|
||||
type CreateOptions = types.CreateOptions
|
||||
|
||||
// GetOptions configures resource retrieval.
|
||||
type GetOptions = types.GetOptions
|
||||
|
||||
// ListOptions configures resource listing with pagination and filtering.
|
||||
type ListOptions = types.ListOptions
|
||||
|
||||
// DeleteOptions configures resource deletion.
|
||||
type DeleteOptions = types.DeleteOptions
|
||||
|
||||
// UpdateOptions configures resource updates.
|
||||
type UpdateOptions = types.UpdateOptions
|
||||
|
||||
// WatchOptions configures watch behavior.
|
||||
type WatchOptions = types.WatchOptions
|
||||
|
||||
// WaitOptions configures wait behavior. Use context for timeout control.
|
||||
type WaitOptions = types.WaitOptions
|
||||
|
||||
// ExecOptions configures command execution.
|
||||
type ExecOptions = types.ExecOptions
|
||||
@@ -0,0 +1,173 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// SandboxPolicy is the top-level security policy configuration for a sandbox.
|
||||
type SandboxPolicy = types.SandboxPolicy
|
||||
|
||||
// FilesystemPolicy controls which directories the sandbox can access.
|
||||
type FilesystemPolicy = types.FilesystemPolicy
|
||||
|
||||
// LandlockPolicy configures the Linux Landlock LSM.
|
||||
type LandlockPolicy = types.LandlockPolicy
|
||||
|
||||
// ProcessPolicy controls the user and group identity for sandboxed processes.
|
||||
type ProcessPolicy = types.ProcessPolicy
|
||||
|
||||
// PolicyChunk represents a single proposed policy change in the draft inbox.
|
||||
type PolicyChunk = types.PolicyChunk
|
||||
|
||||
// DraftPolicy contains the full draft policy state returned by GetDraft.
|
||||
type DraftPolicy = types.DraftPolicy
|
||||
|
||||
// PolicyStatusResult contains the status of a sandbox's policy.
|
||||
type PolicyStatusResult = types.PolicyStatusResult
|
||||
|
||||
// SandboxPolicyRevision represents a versioned policy revision for a sandbox.
|
||||
type SandboxPolicyRevision = types.SandboxPolicyRevision
|
||||
|
||||
// PolicyLoadStatus represents the load state of a policy revision.
|
||||
type PolicyLoadStatus = types.PolicyLoadStatus
|
||||
|
||||
// PolicyLoadStatus constants re-exported from types package.
|
||||
const (
|
||||
PolicyLoadStatusUnspecified = types.PolicyLoadStatusUnspecified
|
||||
PolicyLoadStatusPending = types.PolicyLoadStatusPending
|
||||
PolicyLoadStatusLoaded = types.PolicyLoadStatusLoaded
|
||||
PolicyLoadStatusFailed = types.PolicyLoadStatusFailed
|
||||
PolicyLoadStatusSuperseded = types.PolicyLoadStatusSuperseded
|
||||
)
|
||||
|
||||
// ApproveResult contains the result of approving a single draft chunk.
|
||||
type ApproveResult = types.ApproveResult
|
||||
|
||||
// ApproveAllResult contains the result of approving all draft chunks.
|
||||
type ApproveAllResult = types.ApproveAllResult
|
||||
|
||||
// UndoResult contains the result of undoing a draft chunk approval.
|
||||
type UndoResult = types.UndoResult
|
||||
|
||||
// ClearResult contains the result of clearing all draft chunks.
|
||||
type ClearResult = types.ClearResult
|
||||
|
||||
// DraftHistoryEntry represents a single event in the draft policy history.
|
||||
type DraftHistoryEntry = types.DraftHistoryEntry
|
||||
|
||||
// GetDraftOption configures a GetDraft call.
|
||||
type GetDraftOption = types.GetDraftOption
|
||||
|
||||
// WithStatusFilter filters draft chunks by approval status.
|
||||
var WithStatusFilter = types.WithStatusFilter
|
||||
|
||||
// ApproveAllOption configures an ApproveAllDraftChunks call.
|
||||
type ApproveAllOption = types.ApproveAllOption
|
||||
|
||||
// WithIncludeSecurityFlagged includes security-flagged chunks in bulk approval.
|
||||
var WithIncludeSecurityFlagged = types.WithIncludeSecurityFlagged
|
||||
|
||||
// GetStatusOption configures a GetStatus call.
|
||||
type GetStatusOption = types.GetStatusOption
|
||||
|
||||
// WithVersion queries a specific policy version instead of the latest.
|
||||
var WithVersion = types.WithVersion
|
||||
|
||||
// ListPolicyOption configures a List call.
|
||||
type ListPolicyOption = types.ListPolicyOption
|
||||
|
||||
// WithLimit sets the maximum number of revisions to return.
|
||||
var WithLimit = types.WithLimit
|
||||
|
||||
// WithOffset sets the pagination offset.
|
||||
var WithOffset = types.WithOffset
|
||||
|
||||
// PolicyInterface defines operations for managing sandbox policy drafts,
|
||||
// approvals, and revision history.
|
||||
type PolicyInterface interface {
|
||||
// GetDraft retrieves the current draft policy for a sandbox, including
|
||||
// all pending, approved, and rejected chunks. Use WithStatusFilter to
|
||||
// return only chunks matching a specific status.
|
||||
//
|
||||
// Errors: NotFound if the sandbox does not exist; InvalidArgument if the
|
||||
// sandbox name is empty; Unimplemented by the fake client.
|
||||
GetDraft(ctx context.Context, workspace, sandboxName string, opts ...GetDraftOption) (*DraftPolicy, error)
|
||||
|
||||
// ApproveDraftChunk approves a single pending draft chunk, merging
|
||||
// its proposed rule into the active policy.
|
||||
//
|
||||
// Errors: NotFound if the sandbox or chunk does not exist;
|
||||
// InvalidArgument if the sandbox name or chunk ID is empty;
|
||||
// Conflict if the chunk has already been approved or rejected;
|
||||
// Unimplemented by the fake client.
|
||||
ApproveDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*ApproveResult, error)
|
||||
|
||||
// RejectDraftChunk rejects a single pending draft chunk with an
|
||||
// optional reason that is fed to future LLM analysis context.
|
||||
//
|
||||
// Errors: NotFound if the sandbox or chunk does not exist;
|
||||
// InvalidArgument if the sandbox name or chunk ID is empty;
|
||||
// Conflict if the chunk has already been approved or rejected;
|
||||
// Unimplemented by the fake client.
|
||||
RejectDraftChunk(ctx context.Context, workspace, sandboxName, chunkID, reason string) error
|
||||
|
||||
// ApproveAllDraftChunks approves all pending draft chunks at once.
|
||||
// By default, security-flagged chunks are skipped. Use
|
||||
// WithIncludeSecurityFlagged to include them.
|
||||
//
|
||||
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
|
||||
// the sandbox name is empty; Unimplemented by the fake client.
|
||||
ApproveAllDraftChunks(ctx context.Context, workspace, sandboxName string, opts ...ApproveAllOption) (*ApproveAllResult, error)
|
||||
|
||||
// ClearDraftChunks removes all pending draft chunks for a sandbox.
|
||||
//
|
||||
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
|
||||
// the sandbox name is empty; Unimplemented by the fake client.
|
||||
ClearDraftChunks(ctx context.Context, workspace, sandboxName string) (*ClearResult, error)
|
||||
|
||||
// GetDraftHistory returns the chronological decision history for a
|
||||
// sandbox's draft policy (approvals, rejections, edits, undos, clears).
|
||||
//
|
||||
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
|
||||
// the sandbox name is empty; Unimplemented by the fake client.
|
||||
GetDraftHistory(ctx context.Context, workspace, sandboxName string) ([]DraftHistoryEntry, error)
|
||||
|
||||
// GetStatus retrieves the policy status for a sandbox, including the
|
||||
// queried revision and the active version. Use WithVersion to query a
|
||||
// specific version instead of the latest.
|
||||
//
|
||||
// Errors: NotFound if the sandbox or requested version does not exist;
|
||||
// InvalidArgument if the sandbox name is empty;
|
||||
// Unimplemented by the fake client.
|
||||
GetStatus(ctx context.Context, workspace, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error)
|
||||
|
||||
// List returns policy revisions for a sandbox, ordered by version.
|
||||
// Use WithLimit and WithOffset for pagination.
|
||||
//
|
||||
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
|
||||
// the sandbox name is empty; Unimplemented by the fake client.
|
||||
List(ctx context.Context, workspace string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error)
|
||||
|
||||
// EditDraftChunk replaces the proposed rule of a pending draft chunk
|
||||
// with the given network policy rule.
|
||||
//
|
||||
// Errors: NotFound if the sandbox or chunk does not exist;
|
||||
// InvalidArgument if the sandbox name, chunk ID, or proposed rule is
|
||||
// empty/nil; Conflict if the chunk is not in a pending state;
|
||||
// Unimplemented by the fake client.
|
||||
EditDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error
|
||||
|
||||
// UndoDraftChunk reverses a previously approved chunk, removing its
|
||||
// merged rule from the active policy.
|
||||
//
|
||||
// Errors: NotFound if the sandbox or chunk does not exist;
|
||||
// InvalidArgument if the sandbox name or chunk ID is empty;
|
||||
// Conflict if the chunk has not been approved;
|
||||
// Unimplemented by the fake client.
|
||||
UndoDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*UndoResult, error)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// ProviderProfile represents a provider type template.
|
||||
type ProviderProfile = types.ProviderProfile
|
||||
|
||||
// ProfileCredential defines a single credential required by a provider profile.
|
||||
type ProfileCredential = types.ProfileCredential
|
||||
|
||||
// ProfileCategory classifies a provider profile.
|
||||
type ProfileCategory = types.ProfileCategory
|
||||
|
||||
// NetworkEndpoint describes a network endpoint provided by a profile.
|
||||
type NetworkEndpoint = types.NetworkEndpoint
|
||||
|
||||
// NetworkBinary describes a binary artifact provided by a profile.
|
||||
type NetworkBinary = types.NetworkBinary
|
||||
|
||||
// ProfileDiscovery holds local discovery configuration for a profile.
|
||||
type ProfileDiscovery = types.ProfileDiscovery
|
||||
|
||||
// ProfileImportItem is an item submitted for profile import or lint validation.
|
||||
type ProfileImportItem = types.ProfileImportItem
|
||||
|
||||
// ProfileDiagnostic is a validation finding from Import, Update, or Lint.
|
||||
type ProfileDiagnostic = types.ProfileDiagnostic
|
||||
|
||||
// ImportResult holds the result of a profile import operation.
|
||||
type ImportResult = types.ImportResult
|
||||
|
||||
// UpdateResult holds the result of a profile update operation.
|
||||
type UpdateResult = types.UpdateResult
|
||||
|
||||
// LintResult holds the result of a profile lint operation.
|
||||
type LintResult = types.LintResult
|
||||
|
||||
// ProfileCategory values.
|
||||
const (
|
||||
ProfileCategoryOther = types.ProfileCategoryOther
|
||||
ProfileCategoryInference = types.ProfileCategoryInference
|
||||
ProfileCategoryAgent = types.ProfileCategoryAgent
|
||||
ProfileCategorySourceControl = types.ProfileCategorySourceControl
|
||||
ProfileCategoryMessaging = types.ProfileCategoryMessaging
|
||||
ProfileCategoryData = types.ProfileCategoryData
|
||||
ProfileCategoryKnowledge = types.ProfileCategoryKnowledge
|
||||
)
|
||||
|
||||
// ProfileInterface defines operations for managing provider profiles.
|
||||
type ProfileInterface interface {
|
||||
// List returns all provider profiles.
|
||||
List(ctx context.Context, workspace string, opts ...ListOptions) ([]*ProviderProfile, error)
|
||||
// Get retrieves a provider profile by ID.
|
||||
Get(ctx context.Context, workspace, id string) (*ProviderProfile, error)
|
||||
// Import submits profiles for import and returns the result with diagnostics.
|
||||
Import(ctx context.Context, workspace string, items []ProfileImportItem) (*ImportResult, error)
|
||||
// Update replaces an existing profile identified by ID and expected resource version.
|
||||
Update(ctx context.Context, workspace, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error)
|
||||
// Lint validates profiles without persisting them and returns diagnostics.
|
||||
Lint(ctx context.Context, workspace string, items []ProfileImportItem) (*LintResult, error)
|
||||
// Delete removes a provider profile by ID. Returns true if deleted.
|
||||
Delete(ctx context.Context, workspace, id string) (bool, error)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// Provider represents an AI provider registration.
|
||||
type Provider = types.Provider
|
||||
|
||||
// ProviderSpec holds provider-specific configuration and credentials.
|
||||
type ProviderSpec = types.ProviderSpec
|
||||
|
||||
// ProviderInterface defines CRUD and Ensure operations on providers,
|
||||
// plus sub-client accessors for profiles and credential refresh.
|
||||
type ProviderInterface interface {
|
||||
Create(ctx context.Context, workspace string, provider *Provider) (*Provider, error)
|
||||
Get(ctx context.Context, workspace, name string) (*Provider, error)
|
||||
List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Provider, error)
|
||||
Update(ctx context.Context, workspace string, provider *Provider) (*Provider, error)
|
||||
Delete(ctx context.Context, workspace, name string) error
|
||||
Ensure(ctx context.Context, workspace string, provider *Provider) (*Provider, error)
|
||||
Profiles() ProfileInterface
|
||||
Refresh() RefreshInterface
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// RefreshStrategy describes how credentials are refreshed.
|
||||
type RefreshStrategy = types.RefreshStrategy
|
||||
|
||||
// RefreshStatus reports the current state of credential refresh for a provider credential.
|
||||
type RefreshStatus = types.RefreshStatus
|
||||
|
||||
// RefreshConfig holds configuration parameters for credential refresh.
|
||||
type RefreshConfig = types.RefreshConfig
|
||||
|
||||
// RefreshStrategy values.
|
||||
const (
|
||||
RefreshStrategyStatic = types.RefreshStrategyStatic
|
||||
RefreshStrategyExternal = types.RefreshStrategyExternal
|
||||
RefreshStrategyOAuth2RefreshToken = types.RefreshStrategyOAuth2RefreshToken
|
||||
RefreshStrategyOAuth2ClientCredentials = types.RefreshStrategyOAuth2ClientCredentials
|
||||
RefreshStrategyGoogleServiceAccountJWT = types.RefreshStrategyGoogleServiceAccountJWT
|
||||
RefreshStrategyAWSStsAssumeRole = types.RefreshStrategyAWSStsAssumeRole
|
||||
)
|
||||
|
||||
// RefreshInterface defines operations for managing provider credential refresh.
|
||||
type RefreshInterface interface {
|
||||
// GetStatus returns the refresh status for a provider's credential.
|
||||
// If credentialKey is empty, statuses for all credentials are returned.
|
||||
GetStatus(ctx context.Context, workspace, provider, credentialKey string) ([]*RefreshStatus, error)
|
||||
// Configure sets up credential refresh for a provider credential.
|
||||
Configure(ctx context.Context, workspace string, config *RefreshConfig) (*RefreshStatus, error)
|
||||
// Rotate triggers an immediate credential rotation.
|
||||
Rotate(ctx context.Context, workspace, provider, credentialKey string) (*RefreshStatus, error)
|
||||
// Delete removes credential refresh configuration. Returns true if deleted.
|
||||
Delete(ctx context.Context, workspace, provider, credentialKey string) (bool, error)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// Sandbox represents a sandbox instance.
|
||||
type Sandbox = types.Sandbox
|
||||
|
||||
// SandboxSpec holds the desired state of a sandbox.
|
||||
type SandboxSpec = types.SandboxSpec
|
||||
|
||||
// SandboxTemplate defines the container template for a sandbox.
|
||||
type SandboxTemplate = types.SandboxTemplate
|
||||
|
||||
// SandboxStatus holds the observed state of a sandbox.
|
||||
type SandboxStatus = types.SandboxStatus
|
||||
|
||||
// SandboxCondition describes an observed condition of a sandbox.
|
||||
type SandboxCondition = types.SandboxCondition
|
||||
|
||||
// AttachProviderResult holds the result of attaching a provider to a sandbox.
|
||||
type AttachProviderResult = types.AttachProviderResult
|
||||
|
||||
// DetachProviderResult holds the result of detaching a provider from a sandbox.
|
||||
type DetachProviderResult = types.DetachProviderResult
|
||||
|
||||
// LogLine represents a single log entry from a sandbox.
|
||||
type LogLine = types.LogLine
|
||||
|
||||
// LogResult contains the result of a GetLogs call.
|
||||
type LogResult = types.LogResult
|
||||
|
||||
// LogOption configures a GetLogs call.
|
||||
type LogOption = types.LogOption
|
||||
|
||||
// WithLogLines sets the maximum number of log lines to return.
|
||||
var WithLogLines = types.WithLogLines
|
||||
|
||||
// WithLogSince filters logs to entries at or after the given time.
|
||||
var WithLogSince = types.WithLogSince
|
||||
|
||||
// WithLogSources filters logs by source (e.g., "gateway", "sandbox").
|
||||
var WithLogSources = types.WithLogSources
|
||||
|
||||
// WithLogMinLevel sets the minimum log level to include.
|
||||
var WithLogMinLevel = types.WithLogMinLevel
|
||||
|
||||
// SandboxInterface defines lifecycle operations on sandboxes.
|
||||
type SandboxInterface interface {
|
||||
Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error)
|
||||
Get(ctx context.Context, workspace, name string) (*Sandbox, error)
|
||||
List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error)
|
||||
Delete(ctx context.Context, workspace, name string) error
|
||||
AttachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*AttachProviderResult, error)
|
||||
DetachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*DetachProviderResult, error)
|
||||
ListProviders(ctx context.Context, workspace, sandboxName string) ([]*Provider, error)
|
||||
WaitReady(ctx context.Context, workspace, name string, opts ...WaitOptions) (*Sandbox, error)
|
||||
Watch(ctx context.Context, workspace, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error)
|
||||
// GetLogs retrieves log entries for a sandbox. The sandbox is resolved
|
||||
// by name (an internal Get call translates name to ID). Use
|
||||
// WithLogLines, WithLogSince, WithLogSources, and WithLogMinLevel to
|
||||
// filter the results.
|
||||
//
|
||||
// Errors: NotFound if the sandbox does not exist; InvalidArgument if
|
||||
// the sandbox name is empty; Unimplemented by the fake client.
|
||||
GetLogs(ctx context.Context, workspace, sandboxName string, opts ...LogOption) (*LogResult, error)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter"
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const defaultPollInterval = 500 * time.Millisecond
|
||||
|
||||
type sandboxClient struct {
|
||||
client pb.OpenShellClient
|
||||
}
|
||||
|
||||
func newSandboxClient(conn grpc.ClientConnInterface) *sandboxClient {
|
||||
return &sandboxClient{client: pb.NewOpenShellClient(conn)}
|
||||
}
|
||||
|
||||
func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) {
|
||||
pbSpec, err := converter.SandboxSpecToProto(spec)
|
||||
if err != nil {
|
||||
return nil, &StatusError{Code: ErrorInvalidArgument, Message: err.Error()}
|
||||
}
|
||||
resp, err := s.client.CreateSandbox(ctx, &pb.CreateSandboxRequest{
|
||||
Name: name,
|
||||
Spec: pbSpec,
|
||||
Labels: labels,
|
||||
Workspace: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, converter.FromGRPCError(err)
|
||||
}
|
||||
return converter.SandboxFromProto(resp.GetSandbox()), nil
|
||||
}
|
||||
|
||||
func (s *sandboxClient) Get(ctx context.Context, workspace, name string) (*Sandbox, error) {
|
||||
resp, err := s.client.GetSandbox(ctx, &pb.GetSandboxRequest{
|
||||
Name: name,
|
||||
Workspace: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, converter.FromGRPCError(err)
|
||||
}
|
||||
return converter.SandboxFromProto(resp.GetSandbox()), nil
|
||||
}
|
||||
|
||||
func (s *sandboxClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) {
|
||||
req := &pb.ListSandboxesRequest{
|
||||
Workspace: workspace,
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
if opts[0].Limit > 0 {
|
||||
req.Limit = uint32(opts[0].Limit)
|
||||
}
|
||||
if opts[0].Offset > 0 {
|
||||
req.Offset = uint32(opts[0].Offset)
|
||||
}
|
||||
req.LabelSelector = opts[0].LabelSelector
|
||||
req.AllWorkspaces = opts[0].AllWorkspaces
|
||||
}
|
||||
|
||||
resp, err := s.client.ListSandboxes(ctx, req)
|
||||
if err != nil {
|
||||
return nil, converter.FromGRPCError(err)
|
||||
}
|
||||
|
||||
sandboxes := make([]*Sandbox, 0, len(resp.GetSandboxes()))
|
||||
for _, proto := range resp.GetSandboxes() {
|
||||
sandboxes = append(sandboxes, converter.SandboxFromProto(proto))
|
||||
}
|
||||
return sandboxes, nil
|
||||
}
|
||||
|
||||
func (s *sandboxClient) Delete(ctx context.Context, workspace, name string) error {
|
||||
_, err := s.client.DeleteSandbox(ctx, &pb.DeleteSandboxRequest{
|
||||
Name: name,
|
||||
Workspace: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
return converter.FromGRPCError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sandboxClient) AttachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*AttachProviderResult, error) {
|
||||
resp, err := s.client.AttachSandboxProvider(ctx, &pb.AttachSandboxProviderRequest{
|
||||
SandboxName: sandboxName,
|
||||
ProviderName: providerName,
|
||||
ExpectedResourceVersion: expectedResourceVersion,
|
||||
Workspace: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, converter.FromGRPCError(err)
|
||||
}
|
||||
return &AttachProviderResult{
|
||||
Sandbox: converter.SandboxFromProto(resp.GetSandbox()),
|
||||
Attached: resp.GetAttached(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sandboxClient) DetachProvider(ctx context.Context, workspace, sandboxName, providerName string, expectedResourceVersion uint64) (*DetachProviderResult, error) {
|
||||
resp, err := s.client.DetachSandboxProvider(ctx, &pb.DetachSandboxProviderRequest{
|
||||
SandboxName: sandboxName,
|
||||
ProviderName: providerName,
|
||||
ExpectedResourceVersion: expectedResourceVersion,
|
||||
Workspace: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, converter.FromGRPCError(err)
|
||||
}
|
||||
return &DetachProviderResult{
|
||||
Sandbox: converter.SandboxFromProto(resp.GetSandbox()),
|
||||
Detached: resp.GetDetached(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sandboxClient) ListProviders(ctx context.Context, workspace, sandboxName string) ([]*Provider, error) {
|
||||
resp, err := s.client.ListSandboxProviders(ctx, &pb.ListSandboxProvidersRequest{
|
||||
SandboxName: sandboxName,
|
||||
Workspace: workspace,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, converter.FromGRPCError(err)
|
||||
}
|
||||
|
||||
providers := make([]*Provider, 0, len(resp.GetProviders()))
|
||||
for _, proto := range resp.GetProviders() {
|
||||
providers = append(providers, converter.ProviderFromProto(proto))
|
||||
}
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
func (s *sandboxClient) WaitReady(ctx context.Context, workspace, name string, opts ...WaitOptions) (*Sandbox, error) {
|
||||
interval := defaultPollInterval
|
||||
if len(opts) > 0 && opts[0].PollInterval > 0 {
|
||||
interval = opts[0].PollInterval
|
||||
}
|
||||
|
||||
sb, err := s.Get(ctx, workspace, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if sb.Status.Phase == SandboxReady {
|
||||
return sb, nil
|
||||
}
|
||||
if sb.Status.Phase == SandboxError {
|
||||
return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)}
|
||||
}
|
||||
if sb.Status.Phase == SandboxDeleting {
|
||||
return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is being deleted", name)}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, contextError(ctx.Err())
|
||||
case <-ticker.C:
|
||||
sb, err = s.Get(ctx, workspace, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sb.Status.Phase == SandboxReady {
|
||||
return sb, nil
|
||||
}
|
||||
if sb.Status.Phase == SandboxError {
|
||||
return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)}
|
||||
}
|
||||
if sb.Status.Phase == SandboxDeleting {
|
||||
return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is being deleted", name)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) {
|
||||
if name == "" {
|
||||
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
|
||||
}
|
||||
|
||||
var watchOpts WatchOptions
|
||||
if len(opts) > 0 {
|
||||
watchOpts = opts[0]
|
||||
}
|
||||
|
||||
// Resolve sandbox name to ID — the proto RPC takes Id, not name.
|
||||
sb, err := s.Get(ctx, workspace, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamCtx, streamCancel := context.WithCancel(ctx)
|
||||
stream, err := s.client.WatchSandbox(streamCtx, &pb.WatchSandboxRequest{
|
||||
Id: sb.ID,
|
||||
FollowStatus: true,
|
||||
StopOnTerminal: watchOpts.StopOnTerminal,
|
||||
})
|
||||
if err != nil {
|
||||
streamCancel()
|
||||
return nil, converter.FromGRPCError(err)
|
||||
}
|
||||
|
||||
first, err := stream.Recv()
|
||||
if err != nil {
|
||||
streamCancel()
|
||||
return nil, converter.FromGRPCError(err)
|
||||
}
|
||||
|
||||
ch := make(chan Event[*Sandbox], 64)
|
||||
w := newWatcher(ch, streamCancel)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer streamCancel()
|
||||
ev := first
|
||||
isFirst := true
|
||||
for {
|
||||
if sbPayload, ok := ev.Payload.(*pb.SandboxStreamEvent_Sandbox); ok && sbPayload.Sandbox != nil {
|
||||
sandbox := converter.SandboxFromProto(sbPayload.Sandbox)
|
||||
eventType := EventModified
|
||||
if isFirst {
|
||||
eventType = EventAdded
|
||||
isFirst = false
|
||||
} else if sandbox.Status.Phase == SandboxDeleting {
|
||||
eventType = EventDeleted
|
||||
}
|
||||
select {
|
||||
case ch <- Event[*Sandbox]{Type: eventType, Object: sandbox}:
|
||||
case <-w.done:
|
||||
return
|
||||
}
|
||||
// StopOnTerminal: close watcher after delivering a terminal phase event
|
||||
if watchOpts.StopOnTerminal && (sandbox.Status.Phase == SandboxReady || sandbox.Status.Phase == SandboxError) {
|
||||
w.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
var recvErr error
|
||||
ev, recvErr = stream.Recv()
|
||||
if recvErr != nil {
|
||||
if recvErr != io.EOF {
|
||||
select {
|
||||
case ch <- Event[*Sandbox]{Type: EventError, Err: converter.FromGRPCError(recvErr)}:
|
||||
case <-w.done:
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (s *sandboxClient) GetLogs(ctx context.Context, workspace, sandboxName string, opts ...LogOption) (*LogResult, error) {
|
||||
// Resolve sandbox name to ID — the proto RPC takes SandboxId, not name.
|
||||
sb, err := s.Get(ctx, workspace, sandboxName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := types.ApplyLogOptions(opts)
|
||||
req := &pb.GetSandboxLogsRequest{
|
||||
SandboxId: sb.ID,
|
||||
Lines: cfg.Lines(),
|
||||
Sources: cfg.Sources(),
|
||||
MinLevel: cfg.MinLevel(),
|
||||
Workspace: workspace,
|
||||
}
|
||||
if !cfg.Since().IsZero() {
|
||||
req.SinceMs = converter.MillisFromTime(cfg.Since())
|
||||
}
|
||||
|
||||
resp, err := s.client.GetSandboxLogs(ctx, req)
|
||||
if err != nil {
|
||||
return nil, converter.FromGRPCError(err)
|
||||
}
|
||||
return converter.LogResultFromProto(resp), nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// ServiceEndpoint represents an exposed HTTP service endpoint within a sandbox.
|
||||
type ServiceEndpoint = types.ServiceEndpoint
|
||||
|
||||
// ServiceInterface defines operations for managing sandbox service endpoints.
|
||||
type ServiceInterface interface {
|
||||
// Expose creates a new service endpoint in the given sandbox.
|
||||
Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error)
|
||||
// Get retrieves a service endpoint by sandbox and service name.
|
||||
Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error)
|
||||
// List returns all service endpoints for a sandbox. An empty sandboxName returns endpoints across all sandboxes.
|
||||
List(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error)
|
||||
// Delete removes a service endpoint by sandbox and service name.
|
||||
Delete(ctx context.Context, workspace, sandboxName, serviceName string) error
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// SSHSession represents an SSH session created for a sandbox.
|
||||
type SSHSession = types.SSHSession
|
||||
|
||||
// tunnelConfig accumulates options for the Tunnel method.
|
||||
type tunnelConfig struct {
|
||||
serviceID string
|
||||
}
|
||||
|
||||
// TunnelOption configures an SSH tunnel opened via [SSHInterface.Tunnel].
|
||||
type TunnelOption func(*tunnelConfig)
|
||||
|
||||
// WithTunnelServiceID sets an optional service identifier on the tunnel's
|
||||
// init frame for audit and correlation purposes.
|
||||
func WithTunnelServiceID(id string) TunnelOption {
|
||||
return func(c *tunnelConfig) {
|
||||
c.serviceID = id
|
||||
}
|
||||
}
|
||||
|
||||
// SSHInterface defines operations for managing SSH sessions.
|
||||
type SSHInterface interface {
|
||||
// CreateSession creates a new SSH session for the given sandbox.
|
||||
// The returned SSHSession contains connection details including the
|
||||
// sensitive Token field that must not be logged.
|
||||
//
|
||||
// Note: CreateSession accepts a raw sandbox ID, not a name.
|
||||
// For name-based access with automatic session lifecycle management,
|
||||
// prefer [SSHInterface.Tunnel] which resolves sandbox names internally
|
||||
// and revokes the session on Close.
|
||||
CreateSession(ctx context.Context, workspace, sandboxID string) (*SSHSession, error)
|
||||
// RevokeSession revokes an existing SSH session by its token.
|
||||
// Returns true if the session was actively revoked, false if it was
|
||||
// already expired or not found.
|
||||
RevokeSession(ctx context.Context, workspace, token string) (bool, error)
|
||||
// Tunnel opens a bidirectional SSH tunnel to the given port inside a
|
||||
// sandbox. It combines CreateSession and ForwardTcp(SshRelayTarget)
|
||||
// into a single call with automatic session cleanup on Close.
|
||||
//
|
||||
// The sandboxName is resolved to a sandbox ID internally. Port must
|
||||
// be in the range 1-65535.
|
||||
//
|
||||
// Errors: InvalidArgument if port is out of range or sandboxName is
|
||||
// empty; NotFound if the sandbox does not exist; Unimplemented by
|
||||
// the fake client; Unavailable if the client is closed.
|
||||
Tunnel(ctx context.Context, workspace, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
)
|
||||
|
||||
func stubError(method string) error {
|
||||
return &StatusError{
|
||||
Code: ErrorUnimplemented,
|
||||
Message: method + " not yet available - see https://github.com/NVIDIA/OpenShell/issues/2270",
|
||||
}
|
||||
}
|
||||
|
||||
// stubExec implements ExecInterface as a placeholder.
|
||||
type stubExec struct{}
|
||||
|
||||
func (s *stubExec) Run(_ context.Context, _, _ string, _ []string, _ ...ExecOptions) (*ExecResult, error) {
|
||||
return nil, stubError("Exec.Run")
|
||||
}
|
||||
func (s *stubExec) Stream(_ context.Context, _, _ string, _ []string, _ ...ExecOptions) (ExecStream, error) {
|
||||
return nil, stubError("Exec.Stream")
|
||||
}
|
||||
func (s *stubExec) Interactive(_ context.Context, _, _ string, _ []string, _, _ uint32, _ ...ExecOptions) (InteractiveSession, error) {
|
||||
return nil, stubError("Exec.Interactive")
|
||||
}
|
||||
|
||||
// stubFiles implements FileInterface as a placeholder.
|
||||
type stubFiles struct{}
|
||||
|
||||
func (s *stubFiles) Upload(_ context.Context, _, _, _, _ string) error {
|
||||
return stubError("Files.Upload")
|
||||
}
|
||||
func (s *stubFiles) Download(_ context.Context, _, _, _, _ string) error {
|
||||
return stubError("Files.Download")
|
||||
}
|
||||
|
||||
// stubHealth implements HealthInterface as a placeholder.
|
||||
type stubHealth struct{}
|
||||
|
||||
func (s *stubHealth) Check(_ context.Context) (*HealthResult, error) {
|
||||
return nil, stubError("Health.Check")
|
||||
}
|
||||
|
||||
// stubProviders implements ProviderInterface as a placeholder.
|
||||
type stubProviders struct{}
|
||||
|
||||
func (s *stubProviders) Create(_ context.Context, _ string, _ *Provider) (*Provider, error) {
|
||||
return nil, stubError("Providers.Create")
|
||||
}
|
||||
func (s *stubProviders) Get(_ context.Context, _, _ string) (*Provider, error) {
|
||||
return nil, stubError("Providers.Get")
|
||||
}
|
||||
func (s *stubProviders) List(_ context.Context, _ string, _ ...ListOptions) ([]*Provider, error) {
|
||||
return nil, stubError("Providers.List")
|
||||
}
|
||||
func (s *stubProviders) Update(_ context.Context, _ string, _ *Provider) (*Provider, error) {
|
||||
return nil, stubError("Providers.Update")
|
||||
}
|
||||
func (s *stubProviders) Delete(_ context.Context, _, _ string) error {
|
||||
return stubError("Providers.Delete")
|
||||
}
|
||||
func (s *stubProviders) Ensure(_ context.Context, _ string, _ *Provider) (*Provider, error) {
|
||||
return nil, stubError("Providers.Ensure")
|
||||
}
|
||||
func (s *stubProviders) Profiles() ProfileInterface { return &stubProfiles{} }
|
||||
func (s *stubProviders) Refresh() RefreshInterface { return &stubRefresh{} }
|
||||
|
||||
// stubProfiles implements ProfileInterface as a placeholder.
|
||||
type stubProfiles struct{}
|
||||
|
||||
func (s *stubProfiles) List(_ context.Context, _ string, _ ...ListOptions) ([]*ProviderProfile, error) {
|
||||
return nil, stubError("Profiles.List")
|
||||
}
|
||||
func (s *stubProfiles) Get(_ context.Context, _, _ string) (*ProviderProfile, error) {
|
||||
return nil, stubError("Profiles.Get")
|
||||
}
|
||||
func (s *stubProfiles) Import(_ context.Context, _ string, _ []ProfileImportItem) (*ImportResult, error) {
|
||||
return nil, stubError("Profiles.Import")
|
||||
}
|
||||
func (s *stubProfiles) Update(_ context.Context, _, _ string, _ uint64, _ ProfileImportItem) (*UpdateResult, error) {
|
||||
return nil, stubError("Profiles.Update")
|
||||
}
|
||||
func (s *stubProfiles) Lint(_ context.Context, _ string, _ []ProfileImportItem) (*LintResult, error) {
|
||||
return nil, stubError("Profiles.Lint")
|
||||
}
|
||||
func (s *stubProfiles) Delete(_ context.Context, _, _ string) (bool, error) {
|
||||
return false, stubError("Profiles.Delete")
|
||||
}
|
||||
|
||||
// stubRefresh implements RefreshInterface as a placeholder.
|
||||
type stubRefresh struct{}
|
||||
|
||||
func (s *stubRefresh) GetStatus(_ context.Context, _, _, _ string) ([]*RefreshStatus, error) {
|
||||
return nil, stubError("Refresh.GetStatus")
|
||||
}
|
||||
func (s *stubRefresh) Configure(_ context.Context, _ string, _ *RefreshConfig) (*RefreshStatus, error) {
|
||||
return nil, stubError("Refresh.Configure")
|
||||
}
|
||||
func (s *stubRefresh) Rotate(_ context.Context, _, _, _ string) (*RefreshStatus, error) {
|
||||
return nil, stubError("Refresh.Rotate")
|
||||
}
|
||||
func (s *stubRefresh) Delete(_ context.Context, _, _, _ string) (bool, error) {
|
||||
return false, stubError("Refresh.Delete")
|
||||
}
|
||||
|
||||
// stubServices implements ServiceInterface as a placeholder.
|
||||
type stubServices struct{}
|
||||
|
||||
func (s *stubServices) Expose(_ context.Context, _, _, _ string, _ uint32, _ bool) (*ServiceEndpoint, error) {
|
||||
return nil, stubError("Services.Expose")
|
||||
}
|
||||
func (s *stubServices) Get(_ context.Context, _, _, _ string) (*ServiceEndpoint, error) {
|
||||
return nil, stubError("Services.Get")
|
||||
}
|
||||
func (s *stubServices) List(_ context.Context, _, _ string, _ ...ListOptions) ([]*ServiceEndpoint, error) {
|
||||
return nil, stubError("Services.List")
|
||||
}
|
||||
func (s *stubServices) Delete(_ context.Context, _, _, _ string) error {
|
||||
return stubError("Services.Delete")
|
||||
}
|
||||
|
||||
// stubSSH implements SSHInterface as a placeholder.
|
||||
type stubSSH struct{}
|
||||
|
||||
func (s *stubSSH) CreateSession(_ context.Context, _, _ string) (*SSHSession, error) {
|
||||
return nil, stubError("SSH.CreateSession")
|
||||
}
|
||||
func (s *stubSSH) RevokeSession(_ context.Context, _, _ string) (bool, error) {
|
||||
return false, stubError("SSH.RevokeSession")
|
||||
}
|
||||
func (s *stubSSH) Tunnel(_ context.Context, _, _ string, _ uint32, _ ...TunnelOption) (io.ReadWriteCloser, error) {
|
||||
return nil, stubError("SSH.Tunnel")
|
||||
}
|
||||
|
||||
// stubTCP implements TCPInterface as a placeholder.
|
||||
type stubTCP struct{}
|
||||
|
||||
func (s *stubTCP) Forward(_ context.Context, _, _ string, _ uint32, _ ...ForwardOption) (io.ReadWriteCloser, error) {
|
||||
return nil, stubError("TCP.Forward")
|
||||
}
|
||||
func (s *stubTCP) Listen(_ context.Context, _, _ string, _, _ uint32, _ ...ListenOption) (net.Listener, error) {
|
||||
return nil, stubError("TCP.Listen")
|
||||
}
|
||||
|
||||
// stubConfig implements ConfigInterface as a placeholder.
|
||||
type stubConfig struct{}
|
||||
|
||||
func (s *stubConfig) GetSandbox(_ context.Context, _, _ string) (*SandboxConfig, error) {
|
||||
return nil, stubError("Config.GetSandbox")
|
||||
}
|
||||
func (s *stubConfig) GetGateway(_ context.Context) (*GatewayConfig, error) {
|
||||
return nil, stubError("Config.GetGateway")
|
||||
}
|
||||
func (s *stubConfig) Update(_ context.Context, _ string, _ *ConfigUpdate) (*ConfigUpdateResult, error) {
|
||||
return nil, stubError("Config.Update")
|
||||
}
|
||||
|
||||
// stubPolicy implements PolicyInterface as a placeholder.
|
||||
type stubPolicy struct{}
|
||||
|
||||
func (s *stubPolicy) GetDraft(_ context.Context, _, _ string, _ ...GetDraftOption) (*DraftPolicy, error) {
|
||||
return nil, stubError("Policy.GetDraft")
|
||||
}
|
||||
func (s *stubPolicy) ApproveDraftChunk(_ context.Context, _, _, _ string) (*ApproveResult, error) {
|
||||
return nil, stubError("Policy.ApproveDraftChunk")
|
||||
}
|
||||
func (s *stubPolicy) RejectDraftChunk(_ context.Context, _, _, _, _ string) error {
|
||||
return stubError("Policy.RejectDraftChunk")
|
||||
}
|
||||
func (s *stubPolicy) ApproveAllDraftChunks(_ context.Context, _, _ string, _ ...ApproveAllOption) (*ApproveAllResult, error) {
|
||||
return nil, stubError("Policy.ApproveAllDraftChunks")
|
||||
}
|
||||
func (s *stubPolicy) ClearDraftChunks(_ context.Context, _, _ string) (*ClearResult, error) {
|
||||
return nil, stubError("Policy.ClearDraftChunks")
|
||||
}
|
||||
func (s *stubPolicy) GetDraftHistory(_ context.Context, _, _ string) ([]DraftHistoryEntry, error) {
|
||||
return nil, stubError("Policy.GetDraftHistory")
|
||||
}
|
||||
func (s *stubPolicy) GetStatus(_ context.Context, _, _ string, _ ...GetStatusOption) (*PolicyStatusResult, error) {
|
||||
return nil, stubError("Policy.GetStatus")
|
||||
}
|
||||
func (s *stubPolicy) List(_ context.Context, _ string, _ ...ListPolicyOption) ([]SandboxPolicyRevision, error) {
|
||||
return nil, stubError("Policy.List")
|
||||
}
|
||||
func (s *stubPolicy) EditDraftChunk(_ context.Context, _, _, _ string, _ *NetworkPolicyRule) error {
|
||||
return stubError("Policy.EditDraftChunk")
|
||||
}
|
||||
func (s *stubPolicy) UndoDraftChunk(_ context.Context, _, _, _ string) (*UndoResult, error) {
|
||||
return nil, stubError("Policy.UndoDraftChunk")
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
)
|
||||
|
||||
// forwardConfig accumulates options for the Forward method.
|
||||
type forwardConfig struct {
|
||||
serviceID string
|
||||
}
|
||||
|
||||
// ForwardOption configures a TCP forward opened via [TCPInterface.Forward].
|
||||
type ForwardOption func(*forwardConfig)
|
||||
|
||||
// WithForwardServiceID sets an optional service identifier on the forward's
|
||||
// init frame for audit and correlation purposes.
|
||||
func WithForwardServiceID(id string) ForwardOption {
|
||||
return func(c *forwardConfig) {
|
||||
c.serviceID = id
|
||||
}
|
||||
}
|
||||
|
||||
// listenConfig accumulates options for the Listen method.
|
||||
type listenConfig struct {
|
||||
bindAddress string
|
||||
useSSHTunnel bool
|
||||
serviceID string
|
||||
}
|
||||
|
||||
// ListenOption configures a local listener opened via [TCPInterface.Listen].
|
||||
type ListenOption func(*listenConfig)
|
||||
|
||||
// WithBindAddress overrides the default local bind address ("127.0.0.1").
|
||||
// Pass "0.0.0.0" to accept connections from any interface.
|
||||
func WithBindAddress(addr string) ListenOption {
|
||||
return func(c *listenConfig) {
|
||||
c.bindAddress = addr
|
||||
}
|
||||
}
|
||||
|
||||
// WithSSHTunnel routes each accepted connection through an SSH tunnel
|
||||
// ([SSHInterface.Tunnel]) instead of the default TCP forward
|
||||
// ([TCPInterface.Forward]).
|
||||
func WithSSHTunnel() ListenOption {
|
||||
return func(c *listenConfig) {
|
||||
c.useSSHTunnel = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithListenServiceID sets an optional service identifier on each tunneled
|
||||
// connection's init frame for audit and correlation purposes.
|
||||
func WithListenServiceID(id string) ListenOption {
|
||||
return func(c *listenConfig) {
|
||||
c.serviceID = id
|
||||
}
|
||||
}
|
||||
|
||||
// TCPInterface defines operations for TCP port forwarding to sandboxes.
|
||||
// Methods accept a sandbox name and resolve it to an ID internally.
|
||||
type TCPInterface interface {
|
||||
// Forward opens a bidirectional TCP connection to the given port inside a
|
||||
// sandbox. The sandbox is identified by name; the SDK resolves it to an
|
||||
// ID internally. The returned io.ReadWriteCloser wraps the underlying
|
||||
// gRPC stream; closing it terminates the stream. Port must be in the
|
||||
// range 1-65535; out-of-range values are rejected client-side with an
|
||||
// InvalidArgument error before opening the gRPC stream.
|
||||
//
|
||||
// The connection respects context cancellation: if ctx is cancelled,
|
||||
// the stream is closed and pending Read/Write calls return a context error.
|
||||
Forward(ctx context.Context, workspace, sandboxName string, port uint32, opts ...ForwardOption) (io.ReadWriteCloser, error)
|
||||
|
||||
// Listen binds a local TCP port and tunnels every accepted connection to
|
||||
// the given port inside a sandbox, returning a standard [net.Listener].
|
||||
// Each call to Accept on the returned listener establishes a new tunnel
|
||||
// to the sandbox port, bridging data bidirectionally.
|
||||
//
|
||||
// The sandbox is identified by name; the SDK resolves it to an ID
|
||||
// internally. remotePort must be in the range 1-65535; localPort must be
|
||||
// in the range 0-65535, where 0 lets the OS assign an ephemeral port
|
||||
// (discoverable via Addr).
|
||||
//
|
||||
// Closing the listener stops accepting new connections, tears down all
|
||||
// active tunnels, and blocks until all bridge goroutines finish.
|
||||
// Cancelling ctx triggers the same shutdown behavior.
|
||||
//
|
||||
// Errors:
|
||||
// - InvalidArgument: sandboxName is empty, remotePort is 0 or > 65535,
|
||||
// or localPort is > 65535
|
||||
// - Unimplemented: returned by the fake client
|
||||
// - Unavailable: client is closed
|
||||
Listen(ctx context.Context, workspace, sandboxName string, remotePort uint32, localPort uint32, opts ...ListenOption) (net.Listener, error)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
|
||||
)
|
||||
|
||||
// SandboxPhase represents the lifecycle phase of a sandbox.
|
||||
type SandboxPhase = types.SandboxPhase
|
||||
|
||||
// SandboxPhase values for sandbox lifecycle.
|
||||
const (
|
||||
SandboxProvisioning = types.SandboxProvisioning
|
||||
SandboxReady = types.SandboxReady
|
||||
SandboxError = types.SandboxError
|
||||
SandboxDeleting = types.SandboxDeleting
|
||||
SandboxUnknown = types.SandboxUnknown
|
||||
)
|
||||
|
||||
// EventType classifies watch events.
|
||||
type EventType = types.EventType
|
||||
|
||||
// EventType values for watch events.
|
||||
const (
|
||||
EventAdded = types.EventAdded
|
||||
EventModified = types.EventModified
|
||||
EventDeleted = types.EventDeleted
|
||||
EventError = types.EventError
|
||||
)
|
||||
|
||||
// StreamType identifies which output stream a chunk belongs to.
|
||||
type StreamType = types.StreamType
|
||||
|
||||
// StreamType values for exec output.
|
||||
const (
|
||||
StreamStdout = types.StreamStdout
|
||||
StreamStderr = types.StreamStderr
|
||||
)
|
||||
|
||||
// TLSConfig holds TLS connection settings.
|
||||
type TLSConfig = types.TLSConfig
|
||||
|
||||
// RetryPolicy configures automatic retry behavior for failed RPCs.
|
||||
type RetryPolicy = types.RetryPolicy
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
import "context"
|
||||
|
||||
// AuthProvider supplies per-RPC credentials. It implements the
|
||||
// grpc credentials.PerRPCCredentials interface.
|
||||
type AuthProvider interface {
|
||||
GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
|
||||
RequireTransportSecurity() bool
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// Config holds all settings needed to create a Client.
|
||||
type Config struct {
|
||||
Address string
|
||||
TLS *TLSConfig
|
||||
Auth AuthProvider
|
||||
// Timeout is reserved for future use. It is not yet applied.
|
||||
Timeout time.Duration
|
||||
// RetryPolicy is reserved for future use. It is not yet applied.
|
||||
RetryPolicy *RetryPolicy
|
||||
// Logger is reserved for future use. It is not yet applied.
|
||||
Logger Logger
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package types defines all domain data types for the OpenShell SDK v1 API.
|
||||
//
|
||||
// These types are the canonical definitions used by both the client layer
|
||||
// (openshell/v1) and the converter layer (openshell/v1/internal/converter).
|
||||
// The v1 package re-exports all types via type aliases for backward
|
||||
// compatibility.
|
||||
package types
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrorCode classifies SDK errors by their gRPC origin.
|
||||
type ErrorCode int
|
||||
|
||||
// ErrorCode values for classifying gRPC errors.
|
||||
const (
|
||||
ErrorNotFound ErrorCode = iota + 1
|
||||
ErrorAlreadyExists
|
||||
ErrorUnavailable
|
||||
ErrorPermissionDenied
|
||||
ErrorInvalidArgument
|
||||
ErrorDeadlineExceeded
|
||||
ErrorCancelled
|
||||
ErrorInternal
|
||||
ErrorUnimplemented
|
||||
ErrorConflict
|
||||
ErrorUnauthenticated
|
||||
)
|
||||
|
||||
// String returns the human-readable name of the error code.
|
||||
func (c ErrorCode) String() string {
|
||||
switch c {
|
||||
case ErrorNotFound:
|
||||
return "NotFound"
|
||||
case ErrorAlreadyExists:
|
||||
return "AlreadyExists"
|
||||
case ErrorUnavailable:
|
||||
return "Unavailable"
|
||||
case ErrorPermissionDenied:
|
||||
return "PermissionDenied"
|
||||
case ErrorInvalidArgument:
|
||||
return "InvalidArgument"
|
||||
case ErrorDeadlineExceeded:
|
||||
return "DeadlineExceeded"
|
||||
case ErrorCancelled:
|
||||
return "Cancelled"
|
||||
case ErrorInternal:
|
||||
return "Internal"
|
||||
case ErrorUnimplemented:
|
||||
return "Unimplemented"
|
||||
case ErrorConflict:
|
||||
return "Conflict"
|
||||
case ErrorUnauthenticated:
|
||||
return "Unauthenticated"
|
||||
default:
|
||||
return fmt.Sprintf("Unknown(%d)", int(c))
|
||||
}
|
||||
}
|
||||
|
||||
// StatusError is the typed error returned by all SDK operations.
|
||||
type StatusError struct {
|
||||
Code ErrorCode
|
||||
Message string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *StatusError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (e *StatusError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// IsNotFound returns true if the error indicates a resource was not found.
|
||||
func IsNotFound(err error) bool {
|
||||
return hasCode(err, ErrorNotFound)
|
||||
}
|
||||
|
||||
// IsAlreadyExists returns true if the error indicates a resource already exists.
|
||||
func IsAlreadyExists(err error) bool {
|
||||
return hasCode(err, ErrorAlreadyExists)
|
||||
}
|
||||
|
||||
// IsUnavailable returns true if the error indicates the service is unavailable.
|
||||
func IsUnavailable(err error) bool {
|
||||
return hasCode(err, ErrorUnavailable)
|
||||
}
|
||||
|
||||
// IsPermissionDenied returns true if the error indicates insufficient permissions.
|
||||
func IsPermissionDenied(err error) bool {
|
||||
return hasCode(err, ErrorPermissionDenied)
|
||||
}
|
||||
|
||||
// IsInvalidArgument returns true if the error indicates an invalid argument.
|
||||
func IsInvalidArgument(err error) bool {
|
||||
return hasCode(err, ErrorInvalidArgument)
|
||||
}
|
||||
|
||||
// IsDeadlineExceeded returns true if the error indicates a deadline was exceeded.
|
||||
func IsDeadlineExceeded(err error) bool {
|
||||
return hasCode(err, ErrorDeadlineExceeded)
|
||||
}
|
||||
|
||||
// IsCancelled returns true if the error indicates the operation was cancelled.
|
||||
func IsCancelled(err error) bool {
|
||||
return hasCode(err, ErrorCancelled)
|
||||
}
|
||||
|
||||
// IsUnimplemented returns true if the error indicates the operation is not implemented.
|
||||
func IsUnimplemented(err error) bool {
|
||||
return hasCode(err, ErrorUnimplemented)
|
||||
}
|
||||
|
||||
// IsConflict returns true if the error indicates a conflict, such as
|
||||
// optimistic concurrency or an invalid state transition.
|
||||
func IsConflict(err error) bool {
|
||||
return hasCode(err, ErrorConflict)
|
||||
}
|
||||
|
||||
// IsUnauthenticated returns true if the error indicates missing or invalid credentials.
|
||||
func IsUnauthenticated(err error) bool {
|
||||
return hasCode(err, ErrorUnauthenticated)
|
||||
}
|
||||
|
||||
func hasCode(err error, code ErrorCode) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var se *StatusError
|
||||
if errors.As(err, &se) {
|
||||
return se.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
// ExecResult holds the collected output of a completed command execution.
|
||||
type ExecResult struct {
|
||||
ExitCode int
|
||||
Stdout []byte
|
||||
Stderr []byte
|
||||
}
|
||||
|
||||
// ExecChunk represents a single chunk of output from a streaming command execution.
|
||||
type ExecChunk struct {
|
||||
Stream StreamType
|
||||
Data []byte
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
// HealthResult holds the result of a health check.
|
||||
type HealthResult struct {
|
||||
Healthy bool
|
||||
Version string
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// LogLine represents a single log entry from a sandbox.
|
||||
type LogLine struct {
|
||||
// Timestamp is when the log entry was recorded.
|
||||
Timestamp time.Time
|
||||
// Level is the log severity level (e.g., "INFO", "WARN", "ERROR").
|
||||
Level string
|
||||
// Target is the log target/module.
|
||||
Target string
|
||||
// Message is the log message text.
|
||||
Message string
|
||||
// Source is the log source: "gateway" or "sandbox".
|
||||
Source string
|
||||
// Fields contains structured key-value fields from the tracing event.
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// LogResult contains the result of a GetLogs call.
|
||||
type LogResult struct {
|
||||
// Lines contains the log entries in chronological order.
|
||||
Lines []LogLine
|
||||
// BufferTotal is the total number of lines in the server's buffer.
|
||||
BufferTotal uint32
|
||||
}
|
||||
|
||||
// logConfig holds configuration for GetLogs calls.
|
||||
type logConfig struct {
|
||||
lines uint32
|
||||
since time.Time
|
||||
sources []string
|
||||
minLevel string
|
||||
}
|
||||
|
||||
// LogOption configures a GetLogs call.
|
||||
type LogOption func(*logConfig)
|
||||
|
||||
// WithLogLines sets the maximum number of log lines to return.
|
||||
func WithLogLines(n uint32) LogOption {
|
||||
return func(c *logConfig) {
|
||||
c.lines = n
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogSince filters logs to entries at or after the given time.
|
||||
func WithLogSince(t time.Time) LogOption {
|
||||
return func(c *logConfig) {
|
||||
c.since = t
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogSources filters logs by source (e.g., "gateway", "sandbox").
|
||||
func WithLogSources(sources ...string) LogOption {
|
||||
return func(c *logConfig) {
|
||||
c.sources = sources
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogMinLevel sets the minimum log level to include.
|
||||
func WithLogMinLevel(level string) LogOption {
|
||||
return func(c *logConfig) {
|
||||
c.minLevel = level
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyLogOptions applies options and returns the config.
|
||||
func ApplyLogOptions(opts []LogOption) logConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
|
||||
var cfg logConfig
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Lines returns the configured max lines (0 means server default).
|
||||
func (c *logConfig) Lines() uint32 {
|
||||
return c.lines
|
||||
}
|
||||
|
||||
// Since returns the configured since timestamp (zero means no filter).
|
||||
func (c *logConfig) Since() time.Time {
|
||||
return c.since
|
||||
}
|
||||
|
||||
// Sources returns the configured source filters.
|
||||
func (c *logConfig) Sources() []string {
|
||||
return c.sources
|
||||
}
|
||||
|
||||
// MinLevel returns the configured minimum log level.
|
||||
func (c *logConfig) MinLevel() string {
|
||||
return c.minLevel
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
// Logger defines structured logging for the SDK. Compatible with logr.Logger
|
||||
// and slog.Logger adapters.
|
||||
type Logger interface {
|
||||
Debug(msg string, keysAndValues ...any)
|
||||
Info(msg string, keysAndValues ...any)
|
||||
Error(err error, msg string, keysAndValues ...any)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
// NetworkPolicyRule defines a named network policy rule containing endpoints and binaries.
|
||||
type NetworkPolicyRule struct {
|
||||
// Name is the map key for this rule in the sandbox policy.
|
||||
Name string
|
||||
// Endpoints lists the network endpoints governed by this rule.
|
||||
Endpoints []PolicyNetworkEndpoint
|
||||
// Binaries lists the binaries governed by this rule.
|
||||
Binaries []PolicyNetworkBinary
|
||||
}
|
||||
|
||||
// PolicyNetworkEndpoint describes a full network endpoint with its access controls
|
||||
// as used in sandbox network policy rules. This is distinct from [NetworkEndpoint]
|
||||
// which is the simplified profile-level endpoint (Host, Port, Protocol only).
|
||||
type PolicyNetworkEndpoint struct {
|
||||
Host string
|
||||
Port uint32
|
||||
Ports []uint32
|
||||
Protocol string
|
||||
TLS string
|
||||
Enforcement string
|
||||
Access string
|
||||
Rules []L7Rule
|
||||
AllowedIPs []string
|
||||
DenyRules []L7DenyRule
|
||||
AllowEncodedSlash bool
|
||||
PersistedQueries string
|
||||
GraphqlPersistedQueries map[string]GraphqlOperation
|
||||
GraphqlMaxBodyBytes uint32
|
||||
Path string
|
||||
WebsocketCredentialRewrite bool
|
||||
RequestBodyCredentialRewrite bool
|
||||
AdvisorProposed bool
|
||||
CredentialSigning string
|
||||
SigningService string
|
||||
SigningRegion string
|
||||
JsonRpcMaxBodyBytes uint32
|
||||
Mcp *McpOptions
|
||||
}
|
||||
|
||||
// PolicyNetworkBinary identifies a binary subject to network policy enforcement.
|
||||
// This is distinct from [NetworkBinary] which is the simplified profile-level binary.
|
||||
type PolicyNetworkBinary struct {
|
||||
// Path is the filesystem path to the binary.
|
||||
Path string
|
||||
}
|
||||
|
||||
// L7Rule wraps an L7 allow rule.
|
||||
type L7Rule struct {
|
||||
// Allow holds the layer-7 allow criteria.
|
||||
Allow *L7Allow
|
||||
}
|
||||
|
||||
// L7Allow specifies layer-7 allow criteria for HTTP/GraphQL/MCP traffic.
|
||||
type L7Allow struct {
|
||||
Method string
|
||||
Path string
|
||||
Command string
|
||||
Query map[string]L7QueryMatcher
|
||||
OperationType string
|
||||
OperationName string
|
||||
Fields []string
|
||||
Params map[string]L7QueryMatcher
|
||||
}
|
||||
|
||||
// L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL/MCP traffic.
|
||||
type L7DenyRule struct {
|
||||
Method string
|
||||
Path string
|
||||
Command string
|
||||
Query map[string]L7QueryMatcher
|
||||
OperationType string
|
||||
OperationName string
|
||||
Fields []string
|
||||
Params map[string]L7QueryMatcher
|
||||
}
|
||||
|
||||
// McpOptions holds MCP-specific policy and inspection options.
|
||||
type McpOptions struct {
|
||||
StrictToolNames *bool
|
||||
AllowAllKnownMcpMethods *bool
|
||||
}
|
||||
|
||||
// L7QueryMatcher matches query parameters by glob pattern or exact values.
|
||||
type L7QueryMatcher struct {
|
||||
Glob string
|
||||
Any []string
|
||||
}
|
||||
|
||||
// GraphqlOperation describes a GraphQL operation for persisted-query validation.
|
||||
type GraphqlOperation struct {
|
||||
OperationType string
|
||||
OperationName string
|
||||
Fields []string
|
||||
}
|
||||
|
||||
// --- MergeOperation types ---
|
||||
|
||||
// PolicyMergeOperation represents a single atomic policy mutation.
|
||||
// Exactly one of the pointer fields must be non-nil, modelling the proto oneof.
|
||||
type PolicyMergeOperation struct {
|
||||
// AddRule adds a new named network policy rule.
|
||||
AddRule *AddNetworkRule
|
||||
// RemoveEndpoint removes a single endpoint from a rule.
|
||||
RemoveEndpoint *RemoveNetworkEndpoint
|
||||
// RemoveRule removes an entire named rule.
|
||||
RemoveRule *RemoveNetworkRule
|
||||
// AddDenyRules appends deny rules to an endpoint.
|
||||
AddDenyRules *AddDenyRules
|
||||
// AddAllowRules appends allow rules to an endpoint.
|
||||
AddAllowRules *AddAllowRules
|
||||
// RemoveBinary removes a binary from a rule.
|
||||
RemoveBinary *RemoveNetworkBinary
|
||||
}
|
||||
|
||||
// AddNetworkRule adds a named network policy rule with a full rule definition.
|
||||
type AddNetworkRule struct {
|
||||
// RuleName is the name key for the rule.
|
||||
RuleName string
|
||||
// Rule is the full network policy rule to add.
|
||||
Rule NetworkPolicyRule
|
||||
}
|
||||
|
||||
// RemoveNetworkEndpoint removes a specific endpoint from a named rule.
|
||||
type RemoveNetworkEndpoint struct {
|
||||
// RuleName is the name of the rule containing the endpoint.
|
||||
RuleName string
|
||||
// Host is the endpoint host to remove.
|
||||
Host string
|
||||
// Port is the endpoint port to remove.
|
||||
Port uint32
|
||||
}
|
||||
|
||||
// RemoveNetworkRule removes an entire named rule from the policy.
|
||||
type RemoveNetworkRule struct {
|
||||
// RuleName is the name of the rule to remove.
|
||||
RuleName string
|
||||
}
|
||||
|
||||
// AddDenyRules appends layer-7 deny rules to a specific endpoint.
|
||||
type AddDenyRules struct {
|
||||
// Host identifies the target endpoint host.
|
||||
Host string
|
||||
// Port identifies the target endpoint port.
|
||||
Port uint32
|
||||
// DenyRules are the deny rules to append.
|
||||
DenyRules []L7DenyRule
|
||||
}
|
||||
|
||||
// AddAllowRules appends layer-7 allow rules to a specific endpoint.
|
||||
type AddAllowRules struct {
|
||||
// Host identifies the target endpoint host.
|
||||
Host string
|
||||
// Port identifies the target endpoint port.
|
||||
Port uint32
|
||||
// Rules are the allow rules to append.
|
||||
Rules []L7Rule
|
||||
}
|
||||
|
||||
// RemoveNetworkBinary removes a binary from a named rule.
|
||||
type RemoveNetworkBinary struct {
|
||||
// RuleName is the name of the rule containing the binary.
|
||||
RuleName string
|
||||
// BinaryPath is the filesystem path of the binary to remove.
|
||||
BinaryPath string
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// CreateOptions configures resource creation.
|
||||
type CreateOptions struct{}
|
||||
|
||||
// GetOptions configures resource retrieval.
|
||||
type GetOptions struct{}
|
||||
|
||||
// ListOptions configures resource listing with pagination and filtering.
|
||||
type ListOptions struct {
|
||||
Limit int
|
||||
Offset int
|
||||
LabelSelector string
|
||||
AllWorkspaces bool
|
||||
}
|
||||
|
||||
// DeleteOptions configures resource deletion.
|
||||
type DeleteOptions struct{}
|
||||
|
||||
// UpdateOptions configures resource updates.
|
||||
type UpdateOptions struct{}
|
||||
|
||||
// WatchOptions configures watch behavior.
|
||||
type WatchOptions struct {
|
||||
// TimeoutSeconds is reserved for future use. Use context for timeout control.
|
||||
TimeoutSeconds int64
|
||||
// LabelSelector is reserved for future use.
|
||||
LabelSelector string
|
||||
// StopOnTerminal causes the watch to close automatically when the sandbox
|
||||
// reaches a terminal phase (Ready or Error).
|
||||
StopOnTerminal bool
|
||||
}
|
||||
|
||||
// WaitOptions configures wait behavior. Use context for timeout control.
|
||||
type WaitOptions struct {
|
||||
PollInterval time.Duration
|
||||
}
|
||||
|
||||
// ExecOptions configures command execution.
|
||||
type ExecOptions struct {
|
||||
Env map[string]string
|
||||
WorkDir string
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// PolicyLoadStatus represents the load state of a policy revision.
|
||||
type PolicyLoadStatus int
|
||||
|
||||
const (
|
||||
// PolicyLoadStatusUnspecified is the default zero value.
|
||||
PolicyLoadStatusUnspecified PolicyLoadStatus = iota
|
||||
// PolicyLoadStatusPending means the policy is queued for loading.
|
||||
PolicyLoadStatusPending
|
||||
// PolicyLoadStatusLoaded means the policy was successfully loaded.
|
||||
PolicyLoadStatusLoaded
|
||||
// PolicyLoadStatusFailed means the policy failed to load.
|
||||
PolicyLoadStatusFailed
|
||||
// PolicyLoadStatusSuperseded means a newer revision replaced this one.
|
||||
PolicyLoadStatusSuperseded
|
||||
)
|
||||
|
||||
// String returns the human-readable name of the load status.
|
||||
func (s PolicyLoadStatus) String() string {
|
||||
switch s {
|
||||
case PolicyLoadStatusUnspecified:
|
||||
return "Unspecified"
|
||||
case PolicyLoadStatusPending:
|
||||
return "Pending"
|
||||
case PolicyLoadStatusLoaded:
|
||||
return "Loaded"
|
||||
case PolicyLoadStatusFailed:
|
||||
return "Failed"
|
||||
case PolicyLoadStatusSuperseded:
|
||||
return "Superseded"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// PolicyChunk represents a single proposed policy change in the draft inbox.
|
||||
type PolicyChunk struct {
|
||||
// ID is the unique chunk identifier.
|
||||
ID string
|
||||
// Status is the approval status: "pending", "approved", "rejected".
|
||||
Status string
|
||||
// RuleName is the proposed network_policies map key.
|
||||
RuleName string
|
||||
// ProposedRule is the proposed network policy rule.
|
||||
ProposedRule *NetworkPolicyRule
|
||||
|
||||
// Rationale is a human-readable explanation of why this rule is proposed.
|
||||
Rationale string
|
||||
// SecurityNotes contains security concerns flagged by analysis (empty if none).
|
||||
SecurityNotes string
|
||||
// Confidence is the analysis confidence score (0.0-1.0).
|
||||
Confidence float32
|
||||
// DenialSummaryIDs lists the IDs of denial summaries that led to this chunk.
|
||||
DenialSummaryIDs []string
|
||||
// CreatedAt is when the chunk was created.
|
||||
CreatedAt time.Time
|
||||
// DecidedAt is when the user approved/rejected (zero if undecided).
|
||||
DecidedAt time.Time
|
||||
// Stage is the recommendation stage: "initial" or "refined".
|
||||
Stage string
|
||||
// SupersedesChunkID is the initial chunk ID this refined chunk replaces.
|
||||
SupersedesChunkID string
|
||||
// HitCount is how many times this endpoint was seen across denial flush cycles.
|
||||
HitCount int32
|
||||
// FirstSeen is the first time this endpoint was proposed.
|
||||
FirstSeen time.Time
|
||||
// LastSeen is the most recent time this endpoint was re-proposed.
|
||||
LastSeen time.Time
|
||||
// Binary is the binary path that triggered the denial.
|
||||
Binary string
|
||||
// ValidationResult is the prover output from gateway-side static checks.
|
||||
ValidationResult string
|
||||
// RejectionReason is the operator-supplied text accompanying a rejection.
|
||||
RejectionReason string
|
||||
}
|
||||
|
||||
// DraftPolicy contains the full draft policy state returned by GetDraft.
|
||||
type DraftPolicy struct {
|
||||
// Chunks contains the draft policy chunks.
|
||||
Chunks []PolicyChunk
|
||||
// RollingSummary is an LLM-generated summary of all analysis.
|
||||
RollingSummary string
|
||||
// DraftVersion is the current draft version number.
|
||||
DraftVersion uint64
|
||||
// LastAnalyzedAt is when the last analysis completed.
|
||||
LastAnalyzedAt time.Time
|
||||
}
|
||||
|
||||
// SandboxPolicy is the top-level security policy configuration for a sandbox.
|
||||
// It contains filesystem access rules, Landlock LSM configuration, process
|
||||
// identity rules, and named network access policies.
|
||||
type SandboxPolicy struct {
|
||||
// Version is the policy version number. The server may override this on write.
|
||||
Version uint32
|
||||
// Filesystem controls which directories the sandbox can access.
|
||||
// Nil means no filesystem policy is specified.
|
||||
Filesystem *FilesystemPolicy
|
||||
// Landlock configures the Linux Landlock LSM.
|
||||
// Nil means no landlock policy is specified.
|
||||
Landlock *LandlockPolicy
|
||||
// Process controls the user and group identity for sandboxed processes.
|
||||
// Nil means no process policy is specified.
|
||||
Process *ProcessPolicy
|
||||
// NetworkPolicies contains named network access rules.
|
||||
// Nil means no network policies are specified; an empty map is distinct from nil.
|
||||
NetworkPolicies map[string]NetworkPolicyRule
|
||||
}
|
||||
|
||||
// FilesystemPolicy controls which directories the sandbox can access
|
||||
// in read-only or read-write mode.
|
||||
type FilesystemPolicy struct {
|
||||
// IncludeWorkdir auto-includes the working directory as read-write.
|
||||
IncludeWorkdir bool
|
||||
// ReadOnly is the list of read-only directory paths.
|
||||
// Nil means no read-only directories; an empty slice is distinct from nil.
|
||||
ReadOnly []string
|
||||
// ReadWrite is the list of read-write directory paths.
|
||||
// Nil means no read-write directories; an empty slice is distinct from nil.
|
||||
ReadWrite []string
|
||||
}
|
||||
|
||||
// LandlockPolicy configures the Linux Landlock LSM for filesystem restriction enforcement.
|
||||
type LandlockPolicy struct {
|
||||
// Compatibility is the compatibility mode (e.g., "best_effort", "hard_requirement").
|
||||
Compatibility string
|
||||
}
|
||||
|
||||
// ProcessPolicy controls the user and group identity under which sandboxed processes execute.
|
||||
type ProcessPolicy struct {
|
||||
// RunAsUser is the user name for sandboxed processes.
|
||||
RunAsUser string
|
||||
// RunAsGroup is the group name for sandboxed processes.
|
||||
RunAsGroup string
|
||||
}
|
||||
|
||||
// SandboxPolicyRevision represents a versioned policy revision for a sandbox.
|
||||
type SandboxPolicyRevision struct {
|
||||
// Version is the policy version (monotonically increasing per sandbox).
|
||||
Version uint32
|
||||
// PolicyHash is the SHA-256 hash of the serialized policy payload.
|
||||
PolicyHash string
|
||||
// Status is the load status of this revision.
|
||||
Status PolicyLoadStatus
|
||||
// LoadError is the error message if status is Failed.
|
||||
LoadError string
|
||||
// CreatedAt is when this revision was created.
|
||||
CreatedAt time.Time
|
||||
// LoadedAt is when this revision was loaded by the sandbox.
|
||||
LoadedAt time.Time
|
||||
// Policy is the typed security policy for this revision. Nil when not requested or absent.
|
||||
Policy *SandboxPolicy
|
||||
}
|
||||
|
||||
// PolicyStatusResult contains the status of a sandbox's policy.
|
||||
type PolicyStatusResult struct {
|
||||
// Revision is the queried policy revision.
|
||||
Revision SandboxPolicyRevision
|
||||
// ActiveVersion is the currently active (loaded) policy version.
|
||||
ActiveVersion uint32
|
||||
}
|
||||
|
||||
// ApproveResult contains the result of approving a single draft chunk.
|
||||
type ApproveResult struct {
|
||||
// PolicyVersion is the new policy version after merge.
|
||||
PolicyVersion uint32
|
||||
// PolicyHash is the SHA-256 hash of the new policy.
|
||||
PolicyHash string
|
||||
}
|
||||
|
||||
// ApproveAllResult contains the result of approving all draft chunks.
|
||||
type ApproveAllResult struct {
|
||||
// PolicyVersion is the new policy version after merge.
|
||||
PolicyVersion uint32
|
||||
// PolicyHash is the SHA-256 hash of the new policy.
|
||||
PolicyHash string
|
||||
// ChunksApproved is the number of chunks approved.
|
||||
ChunksApproved uint32
|
||||
// ChunksSkipped is the number of chunks skipped (security-flagged).
|
||||
ChunksSkipped uint32
|
||||
}
|
||||
|
||||
// UndoResult contains the result of undoing a draft chunk approval.
|
||||
type UndoResult struct {
|
||||
// PolicyVersion is the new policy version after removal.
|
||||
PolicyVersion uint32
|
||||
// PolicyHash is the SHA-256 hash of the updated policy.
|
||||
PolicyHash string
|
||||
}
|
||||
|
||||
// ClearResult contains the result of clearing all draft chunks.
|
||||
type ClearResult struct {
|
||||
// ChunksCleared is the number of chunks cleared.
|
||||
ChunksCleared uint32
|
||||
}
|
||||
|
||||
// DraftHistoryEntry represents a single event in the draft policy history.
|
||||
type DraftHistoryEntry struct {
|
||||
// Timestamp is when the event occurred.
|
||||
Timestamp time.Time
|
||||
// EventType is the event type (e.g., "approved", "rejected", "cleared").
|
||||
EventType string
|
||||
// Description is a human-readable description.
|
||||
Description string
|
||||
// ChunkID is the associated chunk ID (if applicable).
|
||||
ChunkID string
|
||||
}
|
||||
|
||||
// getDraftConfig holds configuration for GetDraft calls.
|
||||
type getDraftConfig struct {
|
||||
statusFilter string
|
||||
}
|
||||
|
||||
// GetDraftOption configures a GetDraft call.
|
||||
type GetDraftOption func(*getDraftConfig)
|
||||
|
||||
// WithStatusFilter filters draft chunks by approval status.
|
||||
func WithStatusFilter(status string) GetDraftOption {
|
||||
return func(c *getDraftConfig) {
|
||||
c.statusFilter = status
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyGetDraftOptions applies options and returns the config.
|
||||
func ApplyGetDraftOptions(opts []GetDraftOption) getDraftConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
|
||||
var cfg getDraftConfig
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// StatusFilter returns the configured status filter.
|
||||
func (c *getDraftConfig) StatusFilter() string {
|
||||
return c.statusFilter
|
||||
}
|
||||
|
||||
// approveAllConfig holds configuration for ApproveAllDraftChunks calls.
|
||||
type approveAllConfig struct {
|
||||
includeSecurityFlagged bool
|
||||
}
|
||||
|
||||
// ApproveAllOption configures an ApproveAllDraftChunks call.
|
||||
type ApproveAllOption func(*approveAllConfig)
|
||||
|
||||
// WithIncludeSecurityFlagged includes security-flagged chunks in bulk approval.
|
||||
func WithIncludeSecurityFlagged() ApproveAllOption {
|
||||
return func(c *approveAllConfig) {
|
||||
c.includeSecurityFlagged = true
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyApproveAllOptions applies options and returns the config.
|
||||
func ApplyApproveAllOptions(opts []ApproveAllOption) approveAllConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
|
||||
var cfg approveAllConfig
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// IncludeSecurityFlagged returns whether security-flagged chunks are included.
|
||||
func (c *approveAllConfig) IncludeSecurityFlagged() bool {
|
||||
return c.includeSecurityFlagged
|
||||
}
|
||||
|
||||
// getStatusConfig holds configuration for GetStatus calls.
|
||||
type getStatusConfig struct {
|
||||
version uint32
|
||||
}
|
||||
|
||||
// GetStatusOption configures a GetStatus call.
|
||||
type GetStatusOption func(*getStatusConfig)
|
||||
|
||||
// WithVersion queries a specific policy version instead of the latest.
|
||||
func WithVersion(version uint32) GetStatusOption {
|
||||
return func(c *getStatusConfig) {
|
||||
c.version = version
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyGetStatusOptions applies options and returns the config.
|
||||
func ApplyGetStatusOptions(opts []GetStatusOption) getStatusConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
|
||||
var cfg getStatusConfig
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Version returns the configured version (0 means latest).
|
||||
func (c *getStatusConfig) Version() uint32 {
|
||||
return c.version
|
||||
}
|
||||
|
||||
// listPolicyConfig holds configuration for List calls.
|
||||
type listPolicyConfig struct {
|
||||
limit uint32
|
||||
offset uint32
|
||||
}
|
||||
|
||||
// ListPolicyOption configures a List call.
|
||||
type ListPolicyOption func(*listPolicyConfig)
|
||||
|
||||
// WithLimit sets the maximum number of revisions to return.
|
||||
func WithLimit(limit uint32) ListPolicyOption {
|
||||
return func(c *listPolicyConfig) {
|
||||
c.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
// WithOffset sets the pagination offset.
|
||||
func WithOffset(offset uint32) ListPolicyOption {
|
||||
return func(c *listPolicyConfig) {
|
||||
c.offset = offset
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyListPolicyOptions applies options and returns the config.
|
||||
func ApplyListPolicyOptions(opts []ListPolicyOption) listPolicyConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package
|
||||
var cfg listPolicyConfig
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Limit returns the configured limit (0 means server default).
|
||||
func (c *listPolicyConfig) Limit() uint32 {
|
||||
return c.limit
|
||||
}
|
||||
|
||||
// Offset returns the configured offset.
|
||||
func (c *listPolicyConfig) Offset() uint32 {
|
||||
return c.offset
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user